mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19e611bbcf | ||
|
|
6eb6af9445 | ||
|
|
f3cc30d0c2 | ||
|
|
ba7c75aff4 | ||
|
|
a53d73ef51 | ||
|
|
c2a63cf425 | ||
|
|
c3456adc2b | ||
|
|
179b569769 | ||
|
|
341adaa07d | ||
|
|
feebfe82fa |
@@ -5,6 +5,7 @@ frontend/dist
|
||||
frontend/build
|
||||
frontend/.vite
|
||||
frontend/.tauri
|
||||
frontend/src-tauri/target
|
||||
|
||||
# Gradle build artifacts
|
||||
.gradle
|
||||
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
build-args: VERSION_TAG=alpha
|
||||
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
|
||||
docker-rev: ["docker/embedded/Dockerfile", "docker/embedded/Dockerfile.ultra-lite", "docker/embedded/Dockerfile.fat"]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
@@ -301,7 +301,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/backend/${{ matrix.docker-rev }}
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
push: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- V2-master
|
||||
- alljavadocker
|
||||
|
||||
# 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
|
||||
@@ -93,10 +94,10 @@ jobs:
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Generate tags for latest (V2-demo branch - test)
|
||||
- name: Generate tags for latest (alljavadocker branch - test)
|
||||
id: meta-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-demo'
|
||||
if: github.ref == 'refs/heads/alljavadocker'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
@@ -110,7 +111,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -149,10 +150,10 @@ jobs:
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Generate tags for latest-fat (V2-demo branch - test)
|
||||
- name: Generate tags for latest-fat (alljavadocker branch - test)
|
||||
id: meta-fat-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-demo'
|
||||
if: github.ref == 'refs/heads/alljavadocker'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
@@ -166,7 +167,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified
|
||||
file: ./docker/embedded/Dockerfile.fat
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -203,10 +204,10 @@ jobs:
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Generate tags for ultra-lite (V2-demo branch - test)
|
||||
- name: Generate tags for ultra-lite (alljavadocker branch - test)
|
||||
id: meta-lite-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-demo'
|
||||
if: github.ref == 'refs/heads/alljavadocker'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
@@ -220,7 +221,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified-lite
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.ultra-lite
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./Dockerfile.fat
|
||||
file: ./docker/embedded/Dockerfile.fat
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
|
||||
@@ -1,173 +1,69 @@
|
||||
<p align="center"><img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80"></p>
|
||||
<h1 align="center">Stirling-PDF</h1>
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80" alt="Stirling PDF logo">
|
||||
</p>
|
||||
|
||||
[](https://hub.docker.com/r/frooodle/s-pdf)
|
||||
[](https://discord.gg/HYmhKj45pU)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF)
|
||||
[](https://github.com/Stirling-Tools/stirling-pdf)
|
||||
<h1 align="center">Stirling PDF - The Open-Source PDF Platform</h1>
|
||||
|
||||
<a href="https://www.producthunt.com/posts/stirling-pdf?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-stirling-pdf" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=641239&theme=light" alt="Stirling PDF - Open source locally hosted web PDF editor | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
[](https://cloud.digitalocean.com/apps/new?repo=https://github.com/Stirling-Tools/Stirling-PDF/tree/digitalOcean&refcode=c3210994b1af)
|
||||
Stirling PDF is a powerful, open-source PDF editing platform. Run it as a personal desktop app, in the browser, or deploy it on your own servers with a private API. Edit, sign, redact, convert, and automate PDFs without sending documents to external services.
|
||||
|
||||
[Stirling-PDF](https://www.stirlingpdf.com) is a robust, locally hosted web-based PDF manipulation tool using Docker. It enables you to carry out various operations on PDF files, including splitting, merging, converting, reorganizing, adding images, rotating, compressing, and more. This locally hosted web application has evolved to encompass a comprehensive set of features, addressing all your PDF requirements.
|
||||
<p align="center">
|
||||
<a href="https://hub.docker.com/r/stirlingtools/stirling-pdf">
|
||||
<img src="https://img.shields.io/docker/pulls/frooodle/s-pdf" alt="Docker Pulls">
|
||||
</a>
|
||||
<a href="https://discord.gg/HYmhKj45pU">
|
||||
<img src="https://img.shields.io/discord/1068636748814483718?label=Discord" alt="Discord">
|
||||
</a>
|
||||
<a href="https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF">
|
||||
<img src="https://api.scorecard.dev/projects/github.com/Stirling-Tools/Stirling-PDF/badge" alt="OpenSSF Scorecard">
|
||||
</a>
|
||||
<a href="https://github.com/Stirling-Tools/stirling-pdf">
|
||||
<img src="https://img.shields.io/github/stars/stirling-tools/stirling-pdf?style=social" alt="GitHub Repo stars">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
All files and PDFs exist either exclusively on the client side, reside in server memory only during task execution, or temporarily reside in a file solely for the execution of the task. Any file downloaded by the user will have been deleted from the server by that point.
|
||||

|
||||
|
||||
Homepage: [https://stirlingpdf.com](https://stirlingpdf.com)
|
||||
## Key Capabilities
|
||||
|
||||
All documentation available at [https://docs.stirlingpdf.com/](https://docs.stirlingpdf.com/)
|
||||
- **Everywhere you work** - Desktop client, browser UI, and self-hosted server with a private API.
|
||||
- **50+ PDF tools** - Edit, merge, split, sign, redact, convert, OCR, compress, and more.
|
||||
- **Automation & workflows** - No-code pipelines direct in UI with APIs to process millions of PDFs.
|
||||
- **Enterprise‑grade** - SSO, auditing, and flexible on‑prem deployments.
|
||||
- **Developer platform** - REST APIs available for nearly all tools to integrate into your existing systems.
|
||||
- **Global UI** - Interface available in 40+ languages.
|
||||
|
||||

|
||||
For a full feature list, see the docs: **https://docs.stirlingpdf.com**
|
||||
|
||||
## Features
|
||||
## Quick Start
|
||||
|
||||
- 50+ PDF Operations
|
||||
- Parallel file processing and downloads
|
||||
- Dark mode support
|
||||
- Custom download options
|
||||
- Custom 'Pipelines' to run multiple features in a automated queue
|
||||
- API for integration with external scripts
|
||||
- Optional Login and Authentication support (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security) for documentation)
|
||||
- Database Backup and Import (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/DATABASE) for documentation)
|
||||
- Enterprise features like SSO (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration) for documentation)
|
||||
```bash
|
||||
docker run -p 8080:8080 docker.stirlingpdf.com/stirlingtools/stirling-pdf
|
||||
```
|
||||
|
||||
## PDF Features
|
||||
Then open: http://localhost:8080
|
||||
|
||||
### Page Operations
|
||||
For full installation options (including desktop and Kubernetes), see our [Documentation Guide](https://docs.stirlingpdf.com/#documentation-guide).
|
||||
|
||||
- View and modify PDFs - View multi-page PDFs with custom viewing, sorting, and searching. Plus, on-page edit features like annotating, drawing, and adding text and images. (Using PDF.js with Joxit and Liberation fonts)
|
||||
- Full interactive GUI for merging/splitting/rotating/moving PDFs and their pages
|
||||
- Merge multiple PDFs into a single resultant file
|
||||
- Split PDFs into multiple files at specified page numbers or extract all pages as individual files
|
||||
- Reorganize PDF pages into different orders
|
||||
- Rotate PDFs in 90-degree increments
|
||||
- Remove pages
|
||||
- Multi-page layout (format PDFs into a multi-paged page)
|
||||
- Scale page contents size by set percentage
|
||||
- Adjust contrast
|
||||
- Crop PDF
|
||||
- Auto-split PDF (with physically scanned page dividers)
|
||||
- Extract page(s)
|
||||
- Convert PDF to a single page
|
||||
- Overlay PDFs on top of each other
|
||||
- PDF to a single page
|
||||
- Split PDF by sections
|
||||
## Resources
|
||||
|
||||
### Conversion Operations
|
||||
- [**Documentation**](https://docs.stirlingpdf.com)
|
||||
- [**Homepage**](https://stirling.com)
|
||||
- [**API Docs**](https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/)
|
||||
- [**Server Plan & Enterprise**](https://docs.stirlingpdf.com/Paid-Offerings)
|
||||
|
||||
- Convert PDFs to and from images
|
||||
- Convert any common file to PDF (using LibreOffice)
|
||||
- Convert PDF to Word/PowerPoint/others (using LibreOffice)
|
||||
- Convert HTML to PDF
|
||||
- Convert PDF to XML
|
||||
- Convert PDF to CSV
|
||||
- URL to PDF
|
||||
- Markdown to PDF
|
||||
## Support
|
||||
|
||||
### Security & Permissions
|
||||
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
|
||||
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
|
||||
|
||||
- Add and remove passwords
|
||||
- Change/set PDF permissions
|
||||
- Add watermark(s)
|
||||
- Certify/sign PDFs
|
||||
- Sanitize PDFs
|
||||
- Auto-redact text
|
||||
## Contributing
|
||||
|
||||
### Other Operations
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
- Add/generate/write signatures
|
||||
- Split by Size or PDF
|
||||
- Repair PDFs
|
||||
- Detect and remove blank pages
|
||||
- Compare two PDFs and show differences in text
|
||||
- Add images to PDFs
|
||||
- Compress PDFs to decrease their filesize (using qpdf)
|
||||
- Extract images from PDF
|
||||
- Remove images from PDF
|
||||
- Extract images from scans
|
||||
- Remove annotations
|
||||
- Add page numbers
|
||||
- Auto-rename files by detecting PDF header text
|
||||
- OCR on PDF (using Tesseract OCR)
|
||||
- PDF/A conversion (using LibreOffice)
|
||||
- Edit metadata
|
||||
- Flatten PDFs
|
||||
- Get all information on a PDF to view or export as JSON
|
||||
- Show/detect embedded JavaScript
|
||||
For development setup, see the [Developer Guide](DeveloperGuide.md).
|
||||
|
||||
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
|
||||
|
||||
## License
|
||||
|
||||
# 📖 Get Started
|
||||
|
||||
Visit our comprehensive documentation at [docs.stirlingpdf.com](https://docs.stirlingpdf.com) for:
|
||||
|
||||
- Installation guides for all platforms
|
||||
- Configuration options
|
||||
- Feature documentation
|
||||
- API reference
|
||||
- Security setup
|
||||
- Enterprise features
|
||||
|
||||
|
||||
## Supported Languages
|
||||
|
||||
Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
Stirling PDF offers an Enterprise edition of its software. This is the same great software but with added features, support and comforts.
|
||||
Check out our [Enterprise docs](https://docs.stirlingpdf.com/Pro)
|
||||
|
||||
|
||||
## 🤝 Looking to contribute?
|
||||
|
||||
Join our community:
|
||||
- [Contribution Guidelines](CONTRIBUTING.md)
|
||||
- [Translation Guide (How to add custom languages)](devGuide/HowToAddNewLanguage.md)
|
||||
- [Developer Guide](devGuide/DeveloperGuide.md)
|
||||
- [Issue Tracker](https://github.com/Stirling-Tools/Stirling-PDF/issues)
|
||||
- [Discord Community](https://discord.gg/HYmhKj45pU)
|
||||
Stirling PDF is open-core. See [LICENSE](LICENSE) for details.
|
||||
|
||||
@@ -39,6 +39,7 @@ public class RequestUriUtils {
|
||||
// Specific static files bundled with the frontend
|
||||
if (normalizedUri.equals("/robots.txt")
|
||||
|| normalizedUri.equals("/favicon.ico")
|
||||
|| normalizedUri.equals("/manifest.json")
|
||||
|| normalizedUri.equals("/site.webmanifest")
|
||||
|| normalizedUri.equals("/manifest-classic.json")
|
||||
|| normalizedUri.equals("/index.html")) {
|
||||
|
||||
+48
-7
@@ -1,20 +1,61 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@Controller
|
||||
public class ReactRoutingController {
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
|
||||
public String forwardRootPaths() {
|
||||
return "forward:/index.html";
|
||||
@Value("${server.servlet.context-path:/}")
|
||||
private String contextPath;
|
||||
|
||||
@GetMapping(value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request)
|
||||
throws IOException {
|
||||
ClassPathResource resource = new ClassPathResource("static/index.html");
|
||||
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
|
||||
// Replace %BASE_URL% with the actual context path for base href
|
||||
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
|
||||
html = html.replace("%BASE_URL%", baseUrl);
|
||||
// Also rewrite any existing <base> tag (Vite may have baked one in)
|
||||
html =
|
||||
html.replaceFirst(
|
||||
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
|
||||
"<base href=\\\"" + baseUrl + "\\\" />");
|
||||
|
||||
// Inject context path as a global variable for API calls
|
||||
String contextPathScript =
|
||||
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
|
||||
html = html.replace("</head>", contextPathScript + "</head>");
|
||||
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(html);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public String forwardNestedPaths() {
|
||||
return "forward:/index.html";
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
|
||||
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request)
|
||||
throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
|
||||
throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -19,9 +19,9 @@ import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
/**
|
||||
* Unified signature image controller that works for both authenticated and unauthenticated users.
|
||||
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared signatures -
|
||||
* PersonalSignatureService (proprietary, optional): reads personal signatures For authenticated
|
||||
* signature management (save/delete), see proprietary SignatureController.
|
||||
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared
|
||||
* signatures - PersonalSignatureService (proprietary, optional): reads personal signatures For
|
||||
* authenticated signature management (save/delete), see proprietary SignatureController.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
|
||||
+5
-1
@@ -324,10 +324,14 @@ public class SecurityConfiguration {
|
||||
.authenticated());
|
||||
// Handle User/Password Logins
|
||||
if (securityProperties.isUserPass()) {
|
||||
// v2: Authentication is handled via API (/api/v1/auth/login), not form login
|
||||
// We configure form login to handle Spring Security redirects,
|
||||
// but use /perform_login as the processing URL so /login remains a React route
|
||||
http.formLogin(
|
||||
formLogin ->
|
||||
formLogin
|
||||
.loginPage("/login")
|
||||
.loginPage("/login") // Redirect here when unauthenticated
|
||||
.loginProcessingUrl("/perform_login") // Process form posts here (not /login)
|
||||
.successHandler(
|
||||
new CustomAuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
|
||||
+6
-1
@@ -283,7 +283,12 @@ public class AdminLicenseController {
|
||||
// Prevent path traversal and enforce single filename component
|
||||
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "Filename must not contain path separators or '..'"));
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Filename must not contain path separators or '..'"));
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
|
||||
+13
@@ -56,6 +56,19 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
|
||||
List<User> findAllSsoUsers();
|
||||
|
||||
/**
|
||||
* Finds SSO users who have never created a session (pending activation) and are not yet
|
||||
* grandfathered.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT u FROM User u "
|
||||
+ "LEFT JOIN SessionEntity s ON u.username = s.principalName "
|
||||
+ "WHERE (u.ssoProvider IS NOT NULL "
|
||||
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')) "
|
||||
+ "AND (u.oauthGrandfathered IS NULL OR u.oauthGrandfathered = false) "
|
||||
+ "AND s.sessionId IS NULL")
|
||||
List<User> findPendingSsoUsersWithoutSession();
|
||||
|
||||
/**
|
||||
* Counts all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
|
||||
*/
|
||||
|
||||
+1
-5
@@ -105,22 +105,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Validating JWT token");
|
||||
jwtService.validateToken(jwtToken);
|
||||
log.debug("JWT token validated successfully");
|
||||
} catch (AuthenticationFailureException e) {
|
||||
log.warn("JWT validation failed: {}", e.getMessage());
|
||||
log.debug("JWT validation failed: {}", e.getMessage());
|
||||
handleAuthenticationFailure(request, response, e);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> claims = jwtService.extractClaims(jwtToken);
|
||||
String tokenUsername = claims.get("sub").toString();
|
||||
log.debug("JWT token username: {}", tokenUsername);
|
||||
|
||||
try {
|
||||
authenticate(request, claims);
|
||||
log.debug("Authentication successful for user: {}", tokenUsername);
|
||||
} catch (SQLException | UnsupportedProviderException e) {
|
||||
log.error("Error processing user authentication for user: {}", tokenUsername, e);
|
||||
handleAuthenticationFailure(
|
||||
|
||||
+1
-5
@@ -50,7 +50,6 @@ public class JwtService implements JwtServiceInterface {
|
||||
KeyPersistenceServiceInterface keyPersistenceService) {
|
||||
this.v2Enabled = v2Enabled;
|
||||
this.keyPersistenceService = keyPersistenceService;
|
||||
log.info("JwtService initialized");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -256,11 +255,9 @@ public class JwtService implements JwtServiceInterface {
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
log.debug("JWT token extracted from Authorization header");
|
||||
return token;
|
||||
}
|
||||
|
||||
log.debug("No JWT token found in Authorization header");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -283,10 +280,9 @@ public class JwtService implements JwtServiceInterface {
|
||||
.parse(token)
|
||||
.getHeader()
|
||||
.get("kid");
|
||||
log.debug("Extracted key ID from token: {}", keyId);
|
||||
return keyId;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract key ID from token header: {}", e.getMessage());
|
||||
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -55,7 +55,6 @@ public class KeyPairCleanupService {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Removing keys older than retention period");
|
||||
removeKeys(eligibleKeys);
|
||||
keyPersistenceService.refreshActiveKeyPair();
|
||||
}
|
||||
|
||||
+26
@@ -778,4 +778,30 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grandfathers SSO users who have never created a session (invited/pending accounts). These
|
||||
* users would otherwise be blocked when SSO requires a paid license despite existing before the
|
||||
* policy change.
|
||||
*
|
||||
* @return Number of pending users updated
|
||||
*/
|
||||
@Transactional
|
||||
public int grandfatherPendingSsoUsersWithoutSession() {
|
||||
List<User> pendingUsers = userRepository.findPendingSsoUsersWithoutSession();
|
||||
int updated = 0;
|
||||
|
||||
for (User user : pendingUsers) {
|
||||
if (!user.isOauthGrandfathered()) {
|
||||
user.setOauthGrandfathered(true);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
userRepository.saveAll(pendingUsers);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-4
@@ -192,10 +192,18 @@ public class UserLicenseSettingsService {
|
||||
+ "They will retain OAuth access even without a paid license. "
|
||||
+ "New users will require a paid license for OAuth.",
|
||||
updated);
|
||||
} else if (grandfatheredCount > 0) {
|
||||
log.debug(
|
||||
"OAuth grandfathering already completed: {} users grandfathered",
|
||||
grandfatheredCount);
|
||||
}
|
||||
|
||||
// Grandfather pending users (invited but never logged in)
|
||||
// The query filters to non-grandfathered users only, so this is idempotent
|
||||
if (grandfatheredCount > 0 || oauthUsersCount > 0) {
|
||||
int pendingUpdated = userService.grandfatherPendingSsoUsersWithoutSession();
|
||||
if (pendingUpdated > 0) {
|
||||
log.warn(
|
||||
"OAuth GRANDFATHERING: Marked {} pending SSO users (no prior sessions) as"
|
||||
+ " grandfathered.",
|
||||
pendingUpdated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+69
@@ -2,6 +2,9 @@ package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -198,4 +201,70 @@ class UserLicenseSettingsServiceTest {
|
||||
|
||||
assertEquals(5, result, "Should fall back to default 5 users if grandfathered is 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_runsOnlyWhenNoneGrandfathered() {
|
||||
// With grandfatheredCount == 0, should run grandfathering for all users
|
||||
when(userService.countOAuthUsers()).thenReturn(10L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
|
||||
when(userService.grandfatherAllOAuthUsers()).thenReturn(10);
|
||||
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(0);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, times(1)).grandfatherAllOAuthUsers();
|
||||
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_skipsMainButRunsPendingWhenSomeAlreadyGrandfathered() {
|
||||
// V2→V2.1 upgrade: some users already grandfathered, but pending users need to be checked
|
||||
when(userService.countOAuthUsers()).thenReturn(10L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(4L);
|
||||
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(2);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, never()).grandfatherAllOAuthUsers();
|
||||
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_stillChecksPendingWhenAllUsersGrandfathered() {
|
||||
// All active users grandfathered, but still check for pending users
|
||||
when(userService.countOAuthUsers()).thenReturn(10L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(10L);
|
||||
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(0);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, never()).grandfatherAllOAuthUsers();
|
||||
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_skipsWhenNoOAuthUsers() {
|
||||
when(userService.countOAuthUsers()).thenReturn(0L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, never()).grandfatherAllOAuthUsers();
|
||||
verify(userService, never()).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_grandfathersPendingUsersOnFirstRun() {
|
||||
// Pending users (invited but never logged in) should be grandfathered
|
||||
// during the initial grandfathering run (when grandfatheredCount == 0)
|
||||
when(userService.countOAuthUsers()).thenReturn(5L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
|
||||
when(userService.grandfatherAllOAuthUsers()).thenReturn(5);
|
||||
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(3);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, times(1)).grandfatherAllOAuthUsers();
|
||||
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Stirling-PDF Dockerfile - Full version with embedded frontend
|
||||
# Single JAR contains both frontend and backend
|
||||
|
||||
# Stage 1: Build application with embedded frontend
|
||||
FROM gradle:8.14-jdk21 AS build
|
||||
|
||||
# Install Node.js and npm for frontend build
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm --version \
|
||||
&& node --version \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy gradle files for dependency resolution
|
||||
COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy entire project
|
||||
COPY . .
|
||||
|
||||
# Build JAR with embedded frontend (includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 2: Runtime image
|
||||
FROM alpine:3.22.1
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Labels
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF"
|
||||
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Full version"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.vendor="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
|
||||
LABEL maintainer="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.authors="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.version="${VERSION_TAG}"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, API, Spring Boot, React"
|
||||
|
||||
# Copy scripts and fonts
|
||||
COPY scripts /scripts
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
|
||||
# Copy built JAR from build stage
|
||||
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
|
||||
COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
|
||||
# Environment Variables
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
|
||||
UNO_PATH=/usr/lib/libreoffice/program \
|
||||
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
|
||||
PATH=$PATH:/opt/venv/bin \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf
|
||||
|
||||
# Install all dependencies
|
||||
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
|
||||
apk upgrade --no-cache -a && \
|
||||
apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
tini \
|
||||
bash \
|
||||
curl \
|
||||
shadow \
|
||||
su-exec \
|
||||
openssl \
|
||||
openssl-dev \
|
||||
openjdk21-jre \
|
||||
# Doc conversion
|
||||
gcompat \
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
ghostscript \
|
||||
fontforge \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
# OCR MY PDF
|
||||
unpaper \
|
||||
tesseract-ocr-data-eng \
|
||||
tesseract-ocr-data-chi_sim \
|
||||
tesseract-ocr-data-deu \
|
||||
tesseract-ocr-data-fra \
|
||||
tesseract-ocr-data-por \
|
||||
ocrmypdf \
|
||||
# CV
|
||||
py3-opencv \
|
||||
python3 \
|
||||
py3-pip \
|
||||
py3-pillow@testing \
|
||||
py3-pdf2image@testing && \
|
||||
python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install --upgrade pip setuptools && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
|
||||
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
|
||||
mv /usr/share/tessdata /usr/share/tessdata-original && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
|
||||
fc-cache -f -v && \
|
||||
chmod +x /scripts/* && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /tmp/stirling-pdf && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
|
||||
# Set user and run command
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
|
||||
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
|
||||
@@ -0,0 +1,142 @@
|
||||
# Stirling-PDF Dockerfile - Fat version with embedded frontend
|
||||
# Single JAR contains both frontend and backend with extra fonts for air-gapped environments
|
||||
|
||||
# Stage 1: Build application with embedded frontend
|
||||
FROM gradle:8.14-jdk21 AS build
|
||||
|
||||
# Install Node.js and npm for frontend build
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm --version \
|
||||
&& node --version \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy gradle files for dependency resolution
|
||||
COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy entire project
|
||||
COPY . .
|
||||
|
||||
# Build JAR with embedded frontend (includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 2: Runtime image
|
||||
FROM alpine:3.22.1
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Labels
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF Fat"
|
||||
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Fat version with extra fonts for air-gapped environments"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.vendor="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
|
||||
LABEL maintainer="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.authors="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.version="${VERSION_TAG}"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, fat, air-gapped, API, Spring Boot, React"
|
||||
|
||||
# Copy scripts and fonts
|
||||
COPY scripts /scripts
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
|
||||
# Copy built JAR from build stage
|
||||
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
|
||||
COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
|
||||
# Environment Variables
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
FAT_DOCKER=true \
|
||||
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
|
||||
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
|
||||
UNO_PATH=/usr/lib/libreoffice/program \
|
||||
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
|
||||
PATH=$PATH:/opt/venv/bin \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf
|
||||
|
||||
# Install all dependencies plus extra fonts for air-gapped environments
|
||||
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
|
||||
apk upgrade --no-cache -a && \
|
||||
apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
tini \
|
||||
bash \
|
||||
curl \
|
||||
shadow \
|
||||
su-exec \
|
||||
openssl \
|
||||
openssl-dev \
|
||||
openjdk21-jre \
|
||||
# Doc conversion
|
||||
gcompat \
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
ghostscript \
|
||||
fontforge \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
# OCR MY PDF
|
||||
unpaper \
|
||||
tesseract-ocr-data-eng \
|
||||
tesseract-ocr-data-chi_sim \
|
||||
tesseract-ocr-data-deu \
|
||||
tesseract-ocr-data-fra \
|
||||
tesseract-ocr-data-por \
|
||||
ocrmypdf \
|
||||
# Extra fonts for fat version
|
||||
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine \
|
||||
# CV
|
||||
py3-opencv \
|
||||
python3 \
|
||||
py3-pip \
|
||||
py3-pillow@testing \
|
||||
py3-pdf2image@testing && \
|
||||
python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install --upgrade pip setuptools && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
|
||||
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
|
||||
mv /usr/share/tessdata /usr/share/tessdata-original && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
|
||||
fc-cache -f -v && \
|
||||
chmod +x /scripts/* && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /tmp/stirling-pdf && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
|
||||
# Set user and run command
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
|
||||
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
|
||||
@@ -0,0 +1,104 @@
|
||||
# Stirling-PDF Dockerfile - Ultra-lite version with embedded frontend
|
||||
# Single JAR contains both frontend and backend with minimal dependencies
|
||||
|
||||
# Stage 1: Build application with embedded frontend
|
||||
FROM gradle:8.14-jdk21 AS build
|
||||
|
||||
# Install Node.js and npm for frontend build
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm --version \
|
||||
&& node --version \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy gradle files for dependency resolution
|
||||
COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy entire project
|
||||
COPY . .
|
||||
|
||||
# Build ultra-lite JAR with embedded frontend (minimal features)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=true \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 2: Runtime image
|
||||
FROM alpine:3.22.1
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Labels
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF Ultra-Lite"
|
||||
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Ultra-lite version with minimal dependencies"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.vendor="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
|
||||
LABEL maintainer="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.authors="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.version="${VERSION_TAG}"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, ultra-lite, API, Spring Boot, React"
|
||||
|
||||
# Copy scripts
|
||||
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
|
||||
COPY scripts/installFonts.sh /scripts/installFonts.sh
|
||||
|
||||
# Copy built JAR from build stage
|
||||
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
|
||||
COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
|
||||
# Environment Variables
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf \
|
||||
ENDPOINTS_GROUPS_TO_REMOVE=CLI
|
||||
|
||||
# Install minimal dependencies
|
||||
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
|
||||
apk upgrade --no-cache -a && \
|
||||
apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
tini \
|
||||
bash \
|
||||
curl \
|
||||
shadow \
|
||||
su-exec \
|
||||
openjdk21-jre && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
|
||||
mkdir -p /usr/share/fonts/opentype/noto && \
|
||||
chmod +x /scripts/*.sh && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
|
||||
# Set user and run command
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"]
|
||||
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"]
|
||||
+4
-4
@@ -3,21 +3,21 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<base href="%BASE_URL%" />
|
||||
<link rel="icon" href="/modern-logo/favicon.ico" />
|
||||
<link rel="icon" href="modern-logo/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/modern-logo/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
|
||||
<title>Stirling PDF</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<script type="module" src="src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+176
-183
@@ -11,25 +11,26 @@
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^1.4.1",
|
||||
"@embedpdf/engines": "^1.4.1",
|
||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||
"@embedpdf/plugin-bookmark": "^1.4.1",
|
||||
"@embedpdf/plugin-export": "^1.4.1",
|
||||
"@embedpdf/plugin-history": "^1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
||||
"@embedpdf/plugin-loader": "^1.4.1",
|
||||
"@embedpdf/plugin-pan": "^1.4.1",
|
||||
"@embedpdf/plugin-render": "^1.4.1",
|
||||
"@embedpdf/plugin-rotate": "^1.4.1",
|
||||
"@embedpdf/plugin-scroll": "^1.4.1",
|
||||
"@embedpdf/plugin-search": "^1.4.1",
|
||||
"@embedpdf/plugin-selection": "^1.4.1",
|
||||
"@embedpdf/plugin-spread": "^1.4.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.4.1",
|
||||
"@embedpdf/plugin-tiling": "^1.4.1",
|
||||
"@embedpdf/plugin-viewport": "^1.4.1",
|
||||
"@embedpdf/plugin-zoom": "^1.4.1",
|
||||
"@embedpdf/core": "^1.5.0",
|
||||
"@embedpdf/engines": "^1.5.0",
|
||||
"@embedpdf/plugin-annotation": "^1.5.0",
|
||||
"@embedpdf/plugin-bookmark": "^1.5.0",
|
||||
"@embedpdf/plugin-export": "^1.5.0",
|
||||
"@embedpdf/plugin-history": "^1.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.5.0",
|
||||
"@embedpdf/plugin-loader": "^1.5.0",
|
||||
"@embedpdf/plugin-pan": "^1.5.0",
|
||||
"@embedpdf/plugin-print": "^1.5.0",
|
||||
"@embedpdf/plugin-render": "^1.5.0",
|
||||
"@embedpdf/plugin-rotate": "^1.5.0",
|
||||
"@embedpdf/plugin-scroll": "^1.5.0",
|
||||
"@embedpdf/plugin-search": "^1.5.0",
|
||||
"@embedpdf/plugin-selection": "^1.5.0",
|
||||
"@embedpdf/plugin-spread": "^1.5.0",
|
||||
"@embedpdf/plugin-thumbnail": "^1.5.0",
|
||||
"@embedpdf/plugin-tiling": "^1.5.0",
|
||||
"@embedpdf/plugin-viewport": "^1.5.0",
|
||||
"@embedpdf/plugin-zoom": "^1.5.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -455,7 +456,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -499,7 +499,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -576,14 +575,13 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@embedpdf/core": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
|
||||
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz",
|
||||
"integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "1.4.1",
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/engines": "1.5.0",
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -594,13 +592,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/engines": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.4.1.tgz",
|
||||
"integrity": "sha512-yugIb5OwTI/1VnAaEvSYxAd2DvYBPkV/D7wytagyaOq98o3sqzcY2Q9zHt+LhnawA5KKG1e/FDPjCd4qm8gsvg==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.5.0.tgz",
|
||||
"integrity": "sha512-/GzhjHFHWfOaX7vjgFJX/pyq668wYjoda1bZ9MpwF/EF000Wwy2Q0AOhprjldPFz8ASKjwKwqsXmaqrK99yOAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1",
|
||||
"@embedpdf/pdfium": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0",
|
||||
"@embedpdf/pdfium": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -611,31 +609,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/models": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.4.1.tgz",
|
||||
"integrity": "sha512-2nTg8Q1qpplBvspZJXMCZOA+/OILpfdNRPddlplxZXY/Upx0rzKXx/e6pXWW7AuOgtfGneT4h9tMs3A595/PdQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.5.0.tgz",
|
||||
"integrity": "sha512-x/1li3jdag+IzfZkcfRLKLqASLep4v6dgVi3z0JArwaicFra8k1IY2xaVTrwcZyx7pRb/rxvoO9yLHW0Y34NFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/pdfium": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.4.1.tgz",
|
||||
"integrity": "sha512-BekKEK4UNCwzj7xOffKn6WpL0FQHxq+mTj2iGI3N7OwAX2J/BO2G+rDOB+lvojQG+Dkpg8uqm427ZKJDRyLgVQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.5.0.tgz",
|
||||
"integrity": "sha512-PI32t2U4ThZC907n2Iwr8E5WqmC574G83u3V9ysNFl29N9kasrY9RiLSzU4W/yQvXPjIbpQHBsbMKXLjCFBI9w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-annotation": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.4.1.tgz",
|
||||
"integrity": "sha512-d4HibNy6ecyDqx2Y2R8VjaqppSdjNofAJmU6VenOd88wn080sAUqvnkeVJ6ehJH5BoND4ymQrcAkcbVeYK0myA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.5.0.tgz",
|
||||
"integrity": "sha512-mxEPI6xYwOGaf9fYfoywuj6nwA10eHFPBuN066MzwphDk6DOHJGZ3Vq8zNQBXh20c/Lb25PL718D7MZWxZLUHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1",
|
||||
"@embedpdf/utils": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0",
|
||||
"@embedpdf/utils": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-history": "1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.4.1",
|
||||
"@embedpdf/plugin-selection": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-history": "1.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "1.5.0",
|
||||
"@embedpdf/plugin-selection": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -643,15 +641,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-bookmark": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.4.1.tgz",
|
||||
"integrity": "sha512-WnfBJdv+Eq5zsMfwDZ5RlXZMGpvKm/ccL6jlTVwtELBhu3wvhjjbBmZdheEOzHMC3VXMNYDMjCeaXkUG4nWoDA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.5.0.tgz",
|
||||
"integrity": "sha512-s3C9PtVesy5X8Ds/C9TEElFiqfKGRklG/uNPTROpNoolfpi0h7qX2xqqh/9+FzKH2nHjVcPB7Pp432v16h7eRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -659,15 +657,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-export": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz",
|
||||
"integrity": "sha512-g89fREFM/zkt2Ai2Q5dWwDkhXgC/JmVyUniaMgm1fTG/MZ0Z05E7f34DUzX/CKcJyVjxEgl6tojBTMeUbm15bA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.5.0.tgz",
|
||||
"integrity": "sha512-luk68mNW9l2X31qk4b02phKaqDl9aDXUAgHVz1EWrgwXQ3Oz9WEdu60utYARYDiepDo3Caadll8RwctYSf/anA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -676,16 +674,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-history": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
|
||||
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz",
|
||||
"integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -693,16 +690,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-interaction-manager": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
|
||||
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz",
|
||||
"integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -711,16 +707,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-loader": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
|
||||
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz",
|
||||
"integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -729,17 +724,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-pan": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.4.1.tgz",
|
||||
"integrity": "sha512-zmOZJ9dUqXiaV0F5GPf/5WTWf3jAEkiv153Tl3x8HT9Rfff+WQhV48NruCIBAy/T4jVt4aH7D1zt/B/ftvcdkA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.5.0.tgz",
|
||||
"integrity": "sha512-EMQ08dHqLkZmFVuLOO6h3AAinFPQoA1r6OlL9z+p0sswq31JAgd4X7+xjYIpI01z/V3+cTzPHzp7qwob5E4tbA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.4.1",
|
||||
"@embedpdf/plugin-viewport": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "1.5.0",
|
||||
"@embedpdf/plugin-viewport": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -747,17 +742,33 @@
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-render": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
|
||||
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
|
||||
"node_modules/@embedpdf/plugin-print": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-1.5.0.tgz",
|
||||
"integrity": "sha512-rjorvNxAZfO9X4cFZVU9fHnldMWqMceJGmr3mH+yj7KdHePvNDDP+omyZyZKtxlUZENaeDI2h6k5z0GbhBz6sQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0",
|
||||
"svelte": ">=5 <6",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-render": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz",
|
||||
"integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -766,15 +777,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-rotate": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.4.1.tgz",
|
||||
"integrity": "sha512-hVzHkKwMNH3tUhxqJGsj5qTLpYZXbj6E74AEcG0w/fz5FrK7EnofPqt0gRfYmIzxnQGIh+39BRtcp8gmx8UNnw==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.5.0.tgz",
|
||||
"integrity": "sha512-5EmBCsq0VfrE3xWY6ofuVm8S6aK95EbAycRIk1wczcmTdvpsuXZ6P2ZaECUgYMcpZ6uAg4/kGf8X8VVZuCihSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -783,17 +794,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-scroll": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
|
||||
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz",
|
||||
"integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-viewport": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-viewport": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -802,16 +812,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-search": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.4.1.tgz",
|
||||
"integrity": "sha512-8JG4CbOcUsLuT0vHJJ4cECmu+Yn53EokWFUVXi2Mo/XvHjhrQuWmD7+y6s/qQPEpctFYWmUCXTDAX9ynPud+2Q==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.5.0.tgz",
|
||||
"integrity": "sha512-TB5b0H8Iobx/azVUBIlG2ClaKtf0y3/Xi3E/iB8BwvkIE2+g6EGfp8IMXIn8WDXST6bbvJEP31Ab0Ilp6SVkiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-loader": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-loader": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -820,18 +830,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-selection": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
|
||||
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz",
|
||||
"integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.4.1",
|
||||
"@embedpdf/plugin-viewport": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "1.5.0",
|
||||
"@embedpdf/plugin-viewport": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -840,16 +849,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-spread": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.4.1.tgz",
|
||||
"integrity": "sha512-l+SrDVGTiiItkt2cEtzv7V/X5HhmLbYHcQ8CFobGeIKdJtzKS1Nu/JSKqg7Ki7eCNgyPL1yMNfNE92bNKYVN4w==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.5.0.tgz",
|
||||
"integrity": "sha512-3EU5Cp+fPQSiMjvMR/P2kXxXry/RlnxHLs4JeskAaH95QcqWW3VD+DrHkWSiLFkdhI18rNNGNlMc5RvDGvbXGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-loader": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-loader": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -858,16 +867,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-thumbnail": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.4.1.tgz",
|
||||
"integrity": "sha512-bN3msjI0PovazgbPK3LyugYVTwIDo0RyBUhBaG42FgJxeY3hmFOWTPgfUH1QF7twHlySnksIvHRFYR3nViryVw==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.5.0.tgz",
|
||||
"integrity": "sha512-Z2qpyyr5s2M6460KDGu1Vk6rdbQFIoCpnyFAT6e7UaTIKkqJSNpmjqMsBU5PosYCFu/cClpHPvS7tg9/IKAk6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-render": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-render": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -876,18 +885,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-tiling": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.4.1.tgz",
|
||||
"integrity": "sha512-wgTfj5T8HV6KP61iiR63DVNrbVp8sPxTqa1Sm+2/D0jY+EPSSCmpt1/qYWiAXd1X+t78foOjCnfbo7fEMn5/pg==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.5.0.tgz",
|
||||
"integrity": "sha512-0Vx9elHNpMM+zv8hEoZXBEm8Q0+4kU52LxOlTYRr1A5FskF836sUct6g1ngwK1bmfbAfpz+62PnYI2EeilDZig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-render": "1.4.1",
|
||||
"@embedpdf/plugin-scroll": "1.4.1",
|
||||
"@embedpdf/plugin-viewport": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-render": "1.5.0",
|
||||
"@embedpdf/plugin-scroll": "1.5.0",
|
||||
"@embedpdf/plugin-viewport": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -896,16 +905,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-viewport": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
|
||||
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz",
|
||||
"integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -914,19 +922,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-zoom": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.4.1.tgz",
|
||||
"integrity": "sha512-9HocmXnPZxqN06q7kyNAmLjgDHOEW8/8QfgNE3nMpRyNHIgnAjxvsWc9lApgp5ErDPG0cSDt0Cduil6nB3wSBQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.5.0.tgz",
|
||||
"integrity": "sha512-LiDkCd5/IXg2CRORl1Yikan2op+AYXSxhHzCFatyBdwzVj+n4y9I74OwCI62Mar8WDAIMyXZDCQxGPToSm+zDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1",
|
||||
"@embedpdf/models": "1.5.0",
|
||||
"hammerjs": "^2.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.4.1",
|
||||
"@embedpdf/plugin-scroll": "1.4.1",
|
||||
"@embedpdf/plugin-viewport": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "1.5.0",
|
||||
"@embedpdf/plugin-scroll": "1.5.0",
|
||||
"@embedpdf/plugin-viewport": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -935,9 +943,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/utils": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.4.1.tgz",
|
||||
"integrity": "sha512-vvJ51Qsz3PyJWR2YvDMMpJXg4+YqdV7Vn2cusmW9sx+4EnAiBiw0HevEE+FepgFV8k+A0WbwXzmsujDIQJ7R4A==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.5.0.tgz",
|
||||
"integrity": "sha512-L6jsAPQPGM8ne+MMFAd5gqXb1RNEgNyh16VvVUVKcVnJlBhwil59nVeEQ0cwPhjF5qVeY6MQDIOjBzJqkgXOYg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -1056,7 +1064,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -1100,7 +1107,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
||||
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -2131,7 +2137,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
|
||||
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -2182,7 +2187,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
|
||||
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
@@ -2250,7 +2254,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
|
||||
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/core-downloads-tracker": "^7.3.5",
|
||||
@@ -3183,7 +3186,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
|
||||
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
@@ -3302,6 +3304,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
|
||||
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.9.0"
|
||||
}
|
||||
@@ -4078,7 +4081,6 @@
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -4407,7 +4409,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4418,7 +4419,6 @@
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -4488,7 +4488,6 @@
|
||||
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.3",
|
||||
"@typescript-eslint/types": "8.46.3",
|
||||
@@ -5202,6 +5201,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
|
||||
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
@@ -5211,6 +5211,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
|
||||
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
@@ -5221,6 +5222,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
|
||||
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/runtime-core": "3.5.24",
|
||||
@@ -5233,6 +5235,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
|
||||
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
@@ -5259,7 +5262,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5667,6 +5669,7 @@
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -5943,7 +5946,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.19",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
@@ -6991,8 +6993,7 @@
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
|
||||
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
@@ -7387,7 +7388,6 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -7558,7 +7558,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -7725,7 +7724,8 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
@@ -7790,6 +7790,7 @@
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
|
||||
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
}
|
||||
@@ -8880,7 +8881,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -9357,6 +9357,7 @@
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
@@ -9677,7 +9678,6 @@
|
||||
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.19",
|
||||
"@asamuzakjp/dom-selector": "^6.7.3",
|
||||
@@ -10264,7 +10264,8 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
@@ -11410,7 +11411,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -11690,7 +11690,6 @@
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
||||
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
@@ -12073,7 +12072,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -12083,7 +12081,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -13595,6 +13592,7 @@
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -13803,7 +13801,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14105,7 +14102,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14187,7 +14183,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.3.0"
|
||||
},
|
||||
@@ -14392,7 +14387,6 @@
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -14544,7 +14538,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14558,7 +14551,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -15170,7 +15162,8 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
|
||||
+20
-19
@@ -7,25 +7,26 @@
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^1.4.1",
|
||||
"@embedpdf/engines": "^1.4.1",
|
||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||
"@embedpdf/plugin-bookmark": "^1.4.1",
|
||||
"@embedpdf/plugin-export": "^1.4.1",
|
||||
"@embedpdf/plugin-history": "^1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
||||
"@embedpdf/plugin-loader": "^1.4.1",
|
||||
"@embedpdf/plugin-pan": "^1.4.1",
|
||||
"@embedpdf/plugin-render": "^1.4.1",
|
||||
"@embedpdf/plugin-rotate": "^1.4.1",
|
||||
"@embedpdf/plugin-scroll": "^1.4.1",
|
||||
"@embedpdf/plugin-search": "^1.4.1",
|
||||
"@embedpdf/plugin-selection": "^1.4.1",
|
||||
"@embedpdf/plugin-spread": "^1.4.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.4.1",
|
||||
"@embedpdf/plugin-tiling": "^1.4.1",
|
||||
"@embedpdf/plugin-viewport": "^1.4.1",
|
||||
"@embedpdf/plugin-zoom": "^1.4.1",
|
||||
"@embedpdf/core": "^1.5.0",
|
||||
"@embedpdf/engines": "^1.5.0",
|
||||
"@embedpdf/plugin-annotation": "^1.5.0",
|
||||
"@embedpdf/plugin-bookmark": "^1.5.0",
|
||||
"@embedpdf/plugin-export": "^1.5.0",
|
||||
"@embedpdf/plugin-history": "^1.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.5.0",
|
||||
"@embedpdf/plugin-loader": "^1.5.0",
|
||||
"@embedpdf/plugin-pan": "^1.5.0",
|
||||
"@embedpdf/plugin-print": "^1.5.0",
|
||||
"@embedpdf/plugin-render": "^1.5.0",
|
||||
"@embedpdf/plugin-rotate": "^1.5.0",
|
||||
"@embedpdf/plugin-scroll": "^1.5.0",
|
||||
"@embedpdf/plugin-search": "^1.5.0",
|
||||
"@embedpdf/plugin-selection": "^1.5.0",
|
||||
"@embedpdf/plugin-spread": "^1.5.0",
|
||||
"@embedpdf/plugin-thumbnail": "^1.5.0",
|
||||
"@embedpdf/plugin-tiling": "^1.5.0",
|
||||
"@embedpdf/plugin-viewport": "^1.5.0",
|
||||
"@embedpdf/plugin-zoom": "^1.5.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "ابنِ تدفّقات عمل متعددة الخطوات بسلسلة
|
||||
desc = "تراكب ملف PDF فوق آخر"
|
||||
title = "تراكب ملفات PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "محرر نصوص PDF"
|
||||
desc = "مراجعة وتحرير صادرات Stirling PDF بصيغة JSON مع تحرير نصوص مجمّعة وإعادة إنشاء PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "نص,تعليق,تسمية"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "PDF əməliyyatlarını zəncirləyərək çoxaddımlı iş axınları q
|
||||
desc = "Bir PDF-i digərinin üstünə qoyur"
|
||||
title = "Üst-Üstə Qoy"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF Mətn Redaktoru"
|
||||
desc = "Qruplaşdırılmış mətn redaktəsi və PDF yenidən yaradılması ilə Stirling PDF JSON ixraclarını nəzərdən keçirin və redaktə edin"
|
||||
|
||||
[home.addText]
|
||||
tags = "mətn,şərh,etiket"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Създавайте многостъпкови работни проц
|
||||
desc = "Наслагва PDF файлове върху друг PDF"
|
||||
title = "Наслагване PDF-и"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF текстов редактор"
|
||||
desc = "Преглеждайте и редактирайте JSON експорти на Stirling PDF с групово редактиране на текст и повторно генериране на PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "текст,анотация,етикет"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Construeix fluxos de treball multietapa enllaçant accions PDF. Ideal pe
|
||||
desc = "Superposa PDFs sobre un altre PDF"
|
||||
title = "Superposar PDFs"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Editor de text PDF"
|
||||
desc = "Revisa i edita exportacions JSON de Stirling PDF amb edició de text agrupada i regeneració del PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "text,anotació,etiqueta"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Vytvářejte vícekrokové workflow řetězením akcí PDF. Ideální pr
|
||||
desc = "Překryje PDF nad jiným PDF"
|
||||
title = "Překrýt PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Editor textu PDF"
|
||||
desc = "Prohlížejte a upravujte exporty JSON ze Stirling PDF se skupinovými úpravami textu a regenerací PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "text,anotace,štítek"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Byg flertrins-workflows ved at kæde PDF-handlinger sammen. Ideelt til t
|
||||
desc = "Overlejrer PDF'er oven på en anden PDF"
|
||||
title = "Overlejr PDF'er"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF-teksteditor"
|
||||
desc = "Gennemse og rediger Stirling PDF JSON-eksporter med grupperet tekstredigering og regenerering af PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "tekst,annotering,etiket"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Mehrstufige Arbeitsabläufe durch Verkettung von PDF-Aktionen erstellen.
|
||||
desc = "Ein PDF über ein anderes legen"
|
||||
title = "PDFs überlagern"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF-Texteditor"
|
||||
desc = "Stirling PDF JSON-Exporte prüfen und bearbeiten – mit gruppierter Textbearbeitung und PDF-Neuerzeugung"
|
||||
|
||||
[home.addText]
|
||||
tags = "text,annotation,label"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Δημιουργήστε ροές πολλών βημάτων συνδέ
|
||||
desc = "Επικάλυψη PDF πάνω σε άλλο PDF"
|
||||
title = "Επικάλυψη PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Επεξεργαστής κειμένου PDF"
|
||||
desc = "Επιθεωρήστε και επεξεργαστείτε εξαγωγές JSON του Stirling PDF με ομαδοποιημένη επεξεργασία κειμένου και αναδημιουργία PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "κείμενο,σχολιασμός,ετικέτα"
|
||||
|
||||
@@ -919,7 +919,7 @@ title = "Overlay PDFs"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF Text Editor"
|
||||
desc = "Review and edit Stirling PDF JSON exports with grouped text editing and PDF regeneration"
|
||||
desc = "Edit existing text and images inside PDFs"
|
||||
|
||||
[home.addText]
|
||||
tags = "text,annotation,label"
|
||||
@@ -3899,6 +3899,7 @@ toggleSidebar = "Toggle Sidebar"
|
||||
exportSelected = "Export Selected Pages"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
annotationMode = "Toggle Annotation Mode"
|
||||
print = "Print PDF"
|
||||
draw = "Draw"
|
||||
save = "Save"
|
||||
saveChanges = "Save Changes"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Crear flujos de trabajo de múltiples pasos encadenando acciones de PDF.
|
||||
desc = "Superponer PDFs encima de otro PDF"
|
||||
title = "Superponer PDFs"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Editor de texto PDF"
|
||||
desc = "Revise y edite exportaciones JSON de Stirling PDF con edición de texto agrupada y regeneración de PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "texto,anotación,etiqueta"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Eraiki hainbat pausotako workflowak PDF ekintzak kateatuz. Egokia zeregi
|
||||
desc = "Overlays PDFs on-top of another PDF"
|
||||
title = "Gainjarri PDFak"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF testu-editorea"
|
||||
desc = "Berrikusi eta editatu Stirling PDF JSON esportazioak taldekatutako testu-edizioarekin eta PDF birsorkuntzarekin"
|
||||
|
||||
[home.addText]
|
||||
tags = "testua,anotazioa,etiketa"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "ساخت گردشکارهای چندمرحلهای با زنجیر
|
||||
desc = "PDFها را بر روی PDF دیگری همپوشانی میکند"
|
||||
title = "همپوشانی PDFها"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "ویرایشگر متن PDF"
|
||||
desc = "بازبینی و ویرایش خروجیهای JSON Stirling PDF با ویرایش گروهی متن و بازتولید PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "متن,حاشیهنویسی,برچسب"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Créez des workflows multi-étapes en enchaînant des actions PDF. Idéa
|
||||
desc = "Superposer un PDF sur un autre"
|
||||
title = "Superposer des PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Éditeur de texte PDF"
|
||||
desc = "Afficher et modifier les exports JSON de Stirling PDF avec édition de texte groupée et régénération du PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "texte,annotation,étiquette"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Tóg sreafaí oibre ilchéime trí ghníomhartha PDF a nascadh le chéil
|
||||
desc = "Forleagain PDF ar bharr PDF eile"
|
||||
title = "Forleagan PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Eagarthóir Téacs PDF"
|
||||
desc = "Athbhreithnigh agus cuir in eagar onnmhairí JSON ó Stirling PDF le heagarthóireacht téacs ghrúpáilte agus athghiniúint PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "téacs,anótáil,lipéad"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "PDF क्रियाओं को जोड़कर बहु-चर
|
||||
desc = "PDF को दूसरी PDF के ऊपर ओवरले करें"
|
||||
title = "PDF ओवरले करें"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF टेक्स्ट एडिटर"
|
||||
desc = "ग्रुप्ड टेक्स्ट एडिटिंग और PDF पुनर्जनन के साथ Stirling PDF JSON एक्सपोर्ट की समीक्षा व संपादन करें"
|
||||
|
||||
[home.addText]
|
||||
tags = "text,annotation,label"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Izgradite višekoračne tijekove rada povezivanjem PDF radnji. Idealno z
|
||||
desc = "Preklapa PDF-ove na drugi PDF"
|
||||
title = "Preklapanje PDF-ova"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF uređivač teksta"
|
||||
desc = "Pregledajte i uredite Stirling PDF JSON izvoze s grupnim uređivanjem teksta i ponovnim generiranjem PDF-a"
|
||||
|
||||
[home.addText]
|
||||
tags = "tekst,anotacija,oznaka"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Többlépéses munkafolyamatok összeállítása PDF műveletek összef
|
||||
desc = "PDF-ek egymásra helyezése egy másik PDF-en"
|
||||
title = "PDF-ek egymásra helyezése"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF szövegszerkesztő"
|
||||
desc = "Nézze át és szerkessze a Stirling PDF JSON exportokat csoportosított szövegszerkesztéssel és PDF-újragenerálással"
|
||||
|
||||
[home.addText]
|
||||
tags = "szöveg, megjegyzés, címke"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Bangun alur kerja multi-langkah dengan merangkai tindakan PDF. Ideal unt
|
||||
desc = "Menumpuk PDF di atas PDF lain"
|
||||
title = "Tumpuk PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Editor Teks PDF"
|
||||
desc = "Tinjau dan edit ekspor Stirling PDF JSON dengan pengeditan teks terkelompok dan pembuatan ulang PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "teks,anotasi,label"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Crea flussi multi‑step concatenando azioni PDF. Ideale per attività r
|
||||
desc = "Sovrapponi un PDF sopra un altro"
|
||||
title = "Sovrapponi PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Editor testo PDF"
|
||||
desc = "Rivedi e modifica le esportazioni JSON di Stirling PDF con modifica testo raggruppata e rigenerazione del PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "testo,annotazione,etichetta"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "PDF アクションを連結して複数ステップのワークフロ
|
||||
desc = "1つのPDFを別のPDFの上に重ねます"
|
||||
title = "PDFを重ね合わせ"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDFテキストエディター"
|
||||
desc = "グループ化されたテキスト編集とPDF再生成で、Stirling PDF の JSON エクスポートをレビュー・編集します。"
|
||||
|
||||
[home.addText]
|
||||
tags = "テキスト,注釈,ラベル"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "PDF 작업을 연결하여 다단계 워크플로를 구성하세요.
|
||||
desc = "PDF를 다른 PDF 위에 오버레이"
|
||||
title = "PDF 오버레이"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF 텍스트 편집기"
|
||||
desc = "그룹화된 텍스트 편집과 PDF 재생성으로 Stirling PDF의 JSON 내보내기를 검토하고 편집하세요"
|
||||
|
||||
[home.addText]
|
||||
tags = "텍스트,주석,레이블"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "PDF പ്രവർത്തനങ്ങൾ ബന്ധിപ്പി
|
||||
desc = "മറ്റൊരു PDF-ന് മുകളിൽ PDF-കൾ ഓവർലേ ചെയ്യുന്നു"
|
||||
title = "PDF-കൾ ഓവർലേ ചെയ്യുക"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF ടെക്സ്റ്റ് എഡിറ്റർ"
|
||||
desc = "ഗ്രൂപ്പുചെയ്ത ടെക്സ്റ്റ് എഡിറ്റിംഗിനോടും PDF വീണ്ടും സൃഷ്ടിക്കുന്നതോടും കൂടി Stirling PDF JSON എക്സ്പോർട്ടുകൾ റിവ്യൂ ചെയ്ത് എഡിറ്റ് ചെയ്യുക"
|
||||
|
||||
[home.addText]
|
||||
tags = "text,annotation,label"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Bouw workflows met meerdere stappen door PDF-acties te koppelen. Ideaal
|
||||
desc = "Plaatst PDF's over een andere PDF heen"
|
||||
title = "PDF's overlappen"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF-teksteditor"
|
||||
desc = "Bekijk en bewerk Stirling PDF JSON-exporten met gegroepeerde tekstbewerking en het opnieuw genereren van PDF's"
|
||||
|
||||
[home.addText]
|
||||
tags = "tekst,annotatie,label"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Bygg flertrinns arbeidsflyter ved å lenke sammen PDF-handlinger. Ideelt
|
||||
desc = "Legger PDF-er over hverandre"
|
||||
title = "Overlay PDF-er"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF-tekstredigerer"
|
||||
desc = "Gå gjennom og rediger Stirling PDF JSON-eksporter med gruppert tekstredigering og regenerering av PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "tekst,merknad,etikett"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Buduj wieloetapowe przepływy, łącząc akcje PDF. Idealne do powtarzaj
|
||||
desc = "Nakłada dokumenty PDF na siebie"
|
||||
title = "Nałóż PDFa"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Edytor tekstu PDF"
|
||||
desc = "Przeglądaj i edytuj eksporty JSON z Stirling PDF z grupową edycją tekstu i ponowną generacją PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "tekst,adnotacja,etykieta"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Crie fluxos de trabalho de várias etapas encadeando ações de PDF. Ide
|
||||
desc = "Sobrepor um PDF sobre outro"
|
||||
title = "Sobrepor PDFs"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Editor de texto de PDF"
|
||||
desc = "Revise e edite exportações JSON do Stirling PDF com edição de texto agrupada e regeneração do PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "texto,anotação,rótulo"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Crie fluxos de trabalho de vários passos encadeando ações de PDF. Ide
|
||||
desc = "Sobrepõe PDFs em cima de outro PDF"
|
||||
title = "Sobrepor PDFs"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Editor de texto PDF"
|
||||
desc = "Revise e edite exportações JSON do Stirling PDF com edição de texto agrupado e regeneração de PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "texto,anotação,etiqueta"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Construiți fluxuri cu mai mulți pași legând acțiuni PDF. Ideal pent
|
||||
desc = "Suprapune PDF-uri peste alt PDF"
|
||||
title = "Suprapune PDF-uri"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Editor text PDF"
|
||||
desc = "Revizuiește și editează exporturile JSON Stirling PDF cu editare de text grupată și regenerare PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "text,anotare,etichetă"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Создавайте многошаговые процессы, связ
|
||||
desc = "Наложить один PDF поверх другого"
|
||||
title = "Наложение PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Редактор текста в PDF"
|
||||
desc = "Просмотр и редактирование экспортов Stirling PDF в JSON с групповым редактированием текста и регенерацией PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "текст,аннотация,ярлык"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Stavať viacstupňové pracovné postupy spájaním akcií PDF. Ideálne
|
||||
desc = "Prekrýva PDF súbory na iný PDF"
|
||||
title = "Prekrývanie PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Editor textu PDF"
|
||||
desc = "Kontrolujte a upravujte Stirling PDF JSON exporty so skupinovými úpravami textu a opätovným vytvorením PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "text,anotácia,štítok"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Sestavite večkorakovne poteke z veriženjem dejanj PDF. Idealno za pona
|
||||
desc = "Prekriva PDF-je na vrhu drugega PDF-ja"
|
||||
title = "Prekrivanje PDF-jev"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Urejevalnik besedila PDF"
|
||||
desc = "Pregledujte in urejajte Stirling PDF JSON izvoze z urejanjem združenega besedila in ponovnim ustvarjanjem PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "besedilo,pripomba,oznaka"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Gradite višekorake tokove rada povezivanjem PDF akcija. Idealno za pona
|
||||
desc = "Preklapa PDF-ove jedan preko drugog"
|
||||
title = "Preklapanje PDF-ova"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF uređivač teksta"
|
||||
desc = "Pregledajte i uređujte Stirling PDF JSON izvoze uz grupisano uređivanje teksta i ponovno generisanje PDF-a"
|
||||
|
||||
[home.addText]
|
||||
tags = "tekst,anotacija,oznaka"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Skapa flerstegade arbetsflöden genom att kedja ihop PDF‑åtgärder. P
|
||||
desc = "Överlagrar PDF:er ovanpå en annan PDF"
|
||||
title = "Överlagra PDF:er"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF-textredigerare"
|
||||
desc = "Granska och redigera Stirling PDF JSON-exporter med grupperad textredigering och PDF-återgenerering"
|
||||
|
||||
[home.addText]
|
||||
tags = "text,kommentar,etikett"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "สร้างเวิร์กโฟลว์หลายขั้น
|
||||
desc = "ซ้อนทับ PDF บน PDF อีกไฟล์หนึ่ง"
|
||||
title = "ซ้อนทับ PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "แก้ไขข้อความ PDF"
|
||||
desc = "ตรวจทานและแก้ไขไฟล์ JSON ที่ส่งออกจาก Stirling PDF ด้วยการแก้ไขข้อความแบบกลุ่มและการสร้าง PDF ใหม่"
|
||||
|
||||
[home.addText]
|
||||
tags = "text,annotation,label"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "PDF eylemlerini birbirine bağlayarak çok adımlı iş akışları olu
|
||||
desc = "PDF'leri başka bir PDF'nin üzerine bindirir"
|
||||
title = "PDF'leri Bindirme"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF Metin Düzenleyici"
|
||||
desc = "Gruplu metin düzenleme ve PDF yeniden oluşturma ile Stirling PDF JSON dışa aktarımlarını gözden geçirin ve düzenleyin"
|
||||
|
||||
[home.addText]
|
||||
tags = "metin,ek açıklama,etiket"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Створюйте багатокрокові робочі процес
|
||||
desc = "Накладення одного PDF поверх іншого PDF"
|
||||
title = "Накладення PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Редактор тексту PDF"
|
||||
desc = "Переглядайте й редагуйте JSON-експорти Stirling PDF з груповим редагуванням тексту та повторною генерацією PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "текст,анотація,мітка"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "Xây dựng quy trình nhiều bước bằng cách xâu chuỗi các th
|
||||
desc = "Chồng lớp PDF lên trên PDF khác"
|
||||
title = "Chồng lớp PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Trình chỉnh sửa văn bản PDF"
|
||||
desc = "Xem và chỉnh sửa xuất JSON của Stirling PDF với chỉnh sửa văn bản theo nhóm và tái tạo PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "văn bản,chú thích,nhãn"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "通过串联 PDF 动作构建多步工作流。适合重复性任务。"
|
||||
desc = "PDF གཞན་ཞིག་གི་སྟེང་དུ་ PDF བརྩེགས་པ།"
|
||||
title = "PDF སྟེང་བརྩེགས།"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF 文本编辑器"
|
||||
desc = "审阅并编辑 Stirling PDF 的 JSON 导出,支持分组文本编辑与 PDF 再生成"
|
||||
|
||||
[home.addText]
|
||||
tags = "文本,注释,标签"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "通过串联 PDF 操作构建多步工作流。适合重复性任务。"
|
||||
desc = "将一个 PDF 叠加在另一个之上"
|
||||
title = "叠加 PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF 文本编辑器"
|
||||
desc = "审阅并编辑 Stirling PDF 导出的 JSON,支持分组文本编辑并重新生成 PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "文本,注释,标签"
|
||||
|
||||
@@ -912,9 +912,6 @@ desc = "將多個 PDF 動作串接,建立多步驟工作流程。適合重複
|
||||
desc = "將 PDF 覆蓋在另一個 PDF 上"
|
||||
title = "覆蓋 PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF 文字編輯器"
|
||||
desc = "檢視與編輯 Stirling PDF 的 JSON 匯出,支援群組文字編輯與重新產生 PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "文字,註解,標籤"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AppProviders } from "@app/components/AppProviders";
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
|
||||
// Import global styles
|
||||
import "@app/styles/tailwind.css";
|
||||
@@ -19,7 +19,7 @@ export default function App() {
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
<OnboardingTour />
|
||||
<Onboarding />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
</Suspense>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from
|
||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
import { OnboardingProvider } from "@app/contexts/OnboardingContext";
|
||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
||||
@@ -77,7 +76,6 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<BannerProvider>
|
||||
<OnboardingProvider>
|
||||
<AppConfigProvider
|
||||
retryOptions={appConfigRetryOptions}
|
||||
{...appConfigProviderProps}
|
||||
@@ -113,7 +111,6 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</OnboardingProvider>
|
||||
</BannerProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { FLOW_SEQUENCES, type SlideId } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
|
||||
export type FlowType = 'login-admin' | 'login-user' | 'no-login' | 'no-login-admin';
|
||||
|
||||
export interface FlowConfig {
|
||||
type: FlowType;
|
||||
ids: SlideId[];
|
||||
}
|
||||
|
||||
export function resolveFlow(enableLogin: boolean, isAdmin: boolean, selfReportedAdmin: boolean): FlowConfig {
|
||||
if (!enableLogin) {
|
||||
return selfReportedAdmin
|
||||
? {
|
||||
type: 'no-login-admin',
|
||||
ids: [...FLOW_SEQUENCES.noLoginBase, ...FLOW_SEQUENCES.noLoginAdmin],
|
||||
}
|
||||
: {
|
||||
type: 'no-login',
|
||||
ids: FLOW_SEQUENCES.noLoginBase,
|
||||
};
|
||||
}
|
||||
|
||||
return isAdmin
|
||||
? {
|
||||
type: 'login-admin',
|
||||
ids: FLOW_SEQUENCES.loginAdmin,
|
||||
}
|
||||
: {
|
||||
type: 'login-user',
|
||||
ids: FLOW_SEQUENCES.loginUser,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onb
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
|
||||
interface RenderButtonsProps {
|
||||
interface SlideButtonsProps {
|
||||
slideDefinition: {
|
||||
buttons: ButtonDefinition[];
|
||||
id: string;
|
||||
@@ -16,7 +16,7 @@ interface RenderButtonsProps {
|
||||
onAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
export function renderButtons({ slideDefinition, licenseNotice, flowState, onAction }: RenderButtonsProps) {
|
||||
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
|
||||
const { t } = useTranslation();
|
||||
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
|
||||
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
|
||||
@@ -105,5 +105,4 @@ export function renderButtons({ slideDefinition, licenseNotice, flowState, onAct
|
||||
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
|
||||
export interface InitialOnboardingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
|
||||
onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void;
|
||||
}
|
||||
|
||||
export interface OnboardingState {
|
||||
step: number;
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
selfReportedAdmin: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_STATE: OnboardingState = {
|
||||
step: 0,
|
||||
selectedRole: null,
|
||||
selfReportedAdmin: false,
|
||||
};
|
||||
|
||||
-381
@@ -1,381 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { useOs } from '@app/hooks/useOs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
SLIDE_DEFINITIONS,
|
||||
type ButtonAction,
|
||||
type FlowState,
|
||||
type SlideId,
|
||||
} from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types';
|
||||
import { DOWNLOAD_URLS } from '@app/constants/downloads';
|
||||
|
||||
interface UseInitialOnboardingStateResult {
|
||||
state: OnboardingState;
|
||||
totalSteps: number;
|
||||
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
|
||||
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
|
||||
licenseNotice: LicenseNotice;
|
||||
flowState: FlowState;
|
||||
closeAndMarkSeen: () => void;
|
||||
handleButtonAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
export function useInitialOnboardingState({
|
||||
opened,
|
||||
onClose,
|
||||
onRequestServerLicense,
|
||||
onLicenseNoticeUpdate,
|
||||
}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { startTour } = useOnboarding();
|
||||
const {
|
||||
loginEnabled: loginEnabledFromServer,
|
||||
configIsAdmin,
|
||||
totalUsers: serverTotalUsers,
|
||||
userCountResolved: serverUserCountResolved,
|
||||
freeTierLimit,
|
||||
hasPaidLicense,
|
||||
scenarioKey,
|
||||
setSelfReportedAdmin,
|
||||
isNewServer,
|
||||
} = useServerExperience();
|
||||
const osType = useOs();
|
||||
const navigate = useNavigate();
|
||||
const selectedDownloadUrlRef = useRef<string>('');
|
||||
|
||||
const [state, setState] = useState<OnboardingState>(DEFAULT_STATE);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setState(DEFAULT_STATE);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
resetState();
|
||||
}
|
||||
}, [opened, resetState]);
|
||||
|
||||
const handleRoleSelect = useCallback(
|
||||
(role: 'admin' | 'user' | null) => {
|
||||
const isAdminSelection = role === 'admin';
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedRole: role,
|
||||
selfReportedAdmin: isAdminSelection,
|
||||
}));
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (isAdminSelection) {
|
||||
window.localStorage.setItem('stirling-self-reported-admin', 'true');
|
||||
} else {
|
||||
window.localStorage.removeItem('stirling-self-reported-admin');
|
||||
}
|
||||
}
|
||||
|
||||
setSelfReportedAdmin(isAdminSelection);
|
||||
},
|
||||
[setSelfReportedAdmin],
|
||||
);
|
||||
|
||||
const closeAndMarkSeen = useCallback(() => {
|
||||
if (!preferences.hasSeenIntroOnboarding) {
|
||||
updatePreference('hasSeenIntroOnboarding', true);
|
||||
}
|
||||
onClose();
|
||||
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
|
||||
|
||||
const isAdmin = configIsAdmin;
|
||||
const enableLogin = loginEnabledFromServer;
|
||||
|
||||
const effectiveEnableLogin = enableLogin;
|
||||
const effectiveIsAdmin = isAdmin;
|
||||
const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) {
|
||||
handleRoleSelect('admin');
|
||||
}
|
||||
}, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]);
|
||||
|
||||
const shouldUseServerCount =
|
||||
(effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin;
|
||||
const licenseUserCountFromServer =
|
||||
shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null;
|
||||
|
||||
const effectiveLicenseUserCount = licenseUserCountFromServer ?? null;
|
||||
|
||||
const os = useMemo(() => {
|
||||
switch (osType) {
|
||||
case 'windows':
|
||||
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
|
||||
case 'mac-apple':
|
||||
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
||||
case 'mac-intel':
|
||||
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
|
||||
case 'linux-x64':
|
||||
case 'linux-arm64':
|
||||
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
|
||||
default:
|
||||
return { label: '', url: '' };
|
||||
}
|
||||
}, [osType]);
|
||||
|
||||
const osOptions = useMemo(() => {
|
||||
const options = [
|
||||
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
|
||||
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
|
||||
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
|
||||
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
|
||||
];
|
||||
return options.filter(opt => opt.url);
|
||||
}, []);
|
||||
|
||||
const resolvedFlow = useMemo(
|
||||
() => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin),
|
||||
[effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin],
|
||||
);
|
||||
const shouldSkipSecurityCheck = shouldAssumeAdminForNewServer;
|
||||
const flowSlideIds = useMemo(
|
||||
() =>
|
||||
shouldSkipSecurityCheck
|
||||
? resolvedFlow.ids.filter((id) => id !== 'security-check')
|
||||
: resolvedFlow.ids,
|
||||
[resolvedFlow.ids, shouldSkipSecurityCheck],
|
||||
);
|
||||
const flowType = resolvedFlow.type;
|
||||
const totalSteps = flowSlideIds.length;
|
||||
const maxIndex = Math.max(totalSteps - 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.step >= flowSlideIds.length) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: Math.max(flowSlideIds.length - 1, 0),
|
||||
}));
|
||||
}
|
||||
}, [flowSlideIds.length, state.step]);
|
||||
|
||||
const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1];
|
||||
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
|
||||
|
||||
if (!slideDefinition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scenarioProvidesInfo =
|
||||
scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
|
||||
const scenarioIndicatesAdmin = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('admin')
|
||||
: state.selfReportedAdmin || effectiveIsAdmin;
|
||||
const scenarioIndicatesOverLimit = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('over-limit')
|
||||
: effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit;
|
||||
const scenarioRequiresLicense =
|
||||
scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true;
|
||||
|
||||
const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense;
|
||||
|
||||
const licenseNotice = useMemo<LicenseNotice>(
|
||||
() => ({
|
||||
totalUsers: effectiveLicenseUserCount,
|
||||
freeTierLimit,
|
||||
isOverLimit: scenarioIndicatesOverLimit,
|
||||
requiresLicense: shouldShowServerLicenseInfo,
|
||||
}),
|
||||
[
|
||||
effectiveLicenseUserCount,
|
||||
freeTierLimit,
|
||||
scenarioIndicatesOverLimit,
|
||||
shouldShowServerLicenseInfo,
|
||||
],
|
||||
);
|
||||
|
||||
const requestServerLicenseIfNeeded = useCallback(
|
||||
(options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => {
|
||||
if (!shouldShowServerLicenseInfo) {
|
||||
return;
|
||||
}
|
||||
onRequestServerLicense?.(options);
|
||||
},
|
||||
[onRequestServerLicense, shouldShowServerLicenseInfo],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLicenseNoticeUpdate?.(licenseNotice);
|
||||
}, [licenseNotice, onLicenseNoticeUpdate]);
|
||||
|
||||
// Initialize ref with default URL
|
||||
useEffect(() => {
|
||||
if (!selectedDownloadUrlRef.current && os.url) {
|
||||
selectedDownloadUrlRef.current = os.url;
|
||||
}
|
||||
}, [os.url]);
|
||||
|
||||
const handleDownloadUrlChange = useCallback((url: string) => {
|
||||
selectedDownloadUrlRef.current = url;
|
||||
}, []);
|
||||
|
||||
const currentSlide = slideDefinition.createSlide({
|
||||
osLabel: os.label,
|
||||
osUrl: os.url,
|
||||
osOptions,
|
||||
onDownloadUrlChange: handleDownloadUrlChange,
|
||||
selectedRole: state.selectedRole,
|
||||
onRoleSelect: handleRoleSelect,
|
||||
licenseNotice,
|
||||
loginEnabled: effectiveEnableLogin,
|
||||
});
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: Math.min(prev.step + 1, maxIndex),
|
||||
}));
|
||||
}, [maxIndex]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: Math.max(prev.step - 1, 0),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const launchTour = useCallback(
|
||||
(mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => {
|
||||
if (options?.closeOnboardingSlides) {
|
||||
closeAndMarkSeen();
|
||||
}
|
||||
|
||||
startTour(mode, {
|
||||
source: 'initial-onboarding-modal',
|
||||
metadata: {
|
||||
hasCompletedOnboarding: preferences.hasCompletedOnboarding,
|
||||
toolPanelModePromptSeen: preferences.toolPanelModePromptSeen,
|
||||
selfReportedAdmin: state.selfReportedAdmin,
|
||||
},
|
||||
});
|
||||
},
|
||||
[closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin],
|
||||
);
|
||||
|
||||
const handleButtonAction = useCallback(
|
||||
(action: ButtonAction) => {
|
||||
const currentSlideIdLocal = currentSlideId;
|
||||
const shouldAutoLaunchLoginUserTour =
|
||||
flowType === 'login-user' && currentSlideIdLocal === 'desktop-install';
|
||||
|
||||
switch (action) {
|
||||
case 'next':
|
||||
if (shouldAutoLaunchLoginUserTour) {
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
return;
|
||||
}
|
||||
goNext();
|
||||
return;
|
||||
case 'prev':
|
||||
goPrev();
|
||||
return;
|
||||
case 'close':
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
case 'download-selected': {
|
||||
const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl;
|
||||
if (downloadUrl) {
|
||||
window.open(downloadUrl, '_blank', 'noopener');
|
||||
}
|
||||
if (shouldAutoLaunchLoginUserTour) {
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
return;
|
||||
}
|
||||
goNext();
|
||||
return;
|
||||
}
|
||||
case 'complete-close':
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
case 'security-next':
|
||||
if (!state.selectedRole) {
|
||||
return;
|
||||
}
|
||||
if (state.selectedRole === 'admin') {
|
||||
goNext();
|
||||
} else {
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
}
|
||||
return;
|
||||
case 'launch-admin':
|
||||
requestServerLicenseIfNeeded({
|
||||
deferUntilTourComplete: true,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
launchTour('admin', { closeOnboardingSlides: true });
|
||||
return;
|
||||
case 'launch-tools':
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
return;
|
||||
case 'launch-auto': {
|
||||
const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools';
|
||||
if (launchMode === 'admin') {
|
||||
requestServerLicenseIfNeeded({
|
||||
deferUntilTourComplete: true,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
}
|
||||
launchTour(launchMode, { closeOnboardingSlides: true });
|
||||
return;
|
||||
}
|
||||
case 'skip-to-license':
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
requestServerLicenseIfNeeded({
|
||||
deferUntilTourComplete: false,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
case 'see-plans':
|
||||
closeAndMarkSeen();
|
||||
navigate('/settings/adminPlan');
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
[
|
||||
closeAndMarkSeen,
|
||||
currentSlide,
|
||||
effectiveIsAdmin,
|
||||
flowType,
|
||||
goNext,
|
||||
goPrev,
|
||||
launchTour,
|
||||
navigate,
|
||||
requestServerLicenseIfNeeded,
|
||||
onRequestServerLicense,
|
||||
os.url,
|
||||
state.selectedRole,
|
||||
state.selfReportedAdmin,
|
||||
updatePreference,
|
||||
],
|
||||
);
|
||||
|
||||
const flowState: FlowState = { selectedRole: state.selectedRole };
|
||||
|
||||
return {
|
||||
state,
|
||||
totalSteps,
|
||||
slideDefinition,
|
||||
currentSlide,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
closeAndMarkSeen,
|
||||
handleButtonAction,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useEffect, useMemo, useCallback, useState } from 'react';
|
||||
import { type StepType } from '@reactour/tour';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { isAuthRoute } from '@app/constants/routes';
|
||||
import { dispatchTourState } from '@app/constants/events';
|
||||
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
|
||||
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
|
||||
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
|
||||
import {
|
||||
useServerLicenseRequest,
|
||||
useTourRequest,
|
||||
} from '@app/components/onboarding/useOnboardingEffects';
|
||||
import { useOnboardingDownload } from '@app/components/onboarding/useOnboardingDownload';
|
||||
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
|
||||
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
||||
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
||||
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
|
||||
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
|
||||
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
import AdminAnalyticsChoiceModal from '@app/components/shared/AdminAnalyticsChoiceModal';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
|
||||
export default function Onboarding() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { state, actions } = useOnboardingOrchestrator();
|
||||
const serverExperience = useServerExperience();
|
||||
const onAuthRoute = isAuthRoute(location.pathname);
|
||||
const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state;
|
||||
|
||||
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
|
||||
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
|
||||
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
|
||||
|
||||
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
|
||||
actions.updateRuntimeState({ selectedRole: role });
|
||||
serverExperience.setSelfReportedAdmin(role === 'admin');
|
||||
}, [actions, serverExperience]);
|
||||
|
||||
const handlePasswordChanged = useCallback(() => {
|
||||
actions.updateRuntimeState({ requiresPasswordChange: false });
|
||||
window.location.href = '/login';
|
||||
}, [actions]);
|
||||
|
||||
const handleButtonAction = useCallback((action: ButtonAction) => {
|
||||
switch (action) {
|
||||
case 'next':
|
||||
case 'complete-close':
|
||||
actions.complete();
|
||||
break;
|
||||
case 'prev':
|
||||
actions.prev();
|
||||
break;
|
||||
case 'close':
|
||||
actions.skip();
|
||||
break;
|
||||
case 'download-selected':
|
||||
handleDownloadSelected();
|
||||
actions.complete();
|
||||
break;
|
||||
case 'security-next':
|
||||
if (!runtimeState.selectedRole) return;
|
||||
if (runtimeState.selectedRole !== 'admin') {
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
|
||||
}
|
||||
actions.complete();
|
||||
break;
|
||||
case 'launch-admin':
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' });
|
||||
actions.complete();
|
||||
break;
|
||||
case 'launch-tools':
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
|
||||
actions.complete();
|
||||
break;
|
||||
case 'launch-auto': {
|
||||
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools';
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType });
|
||||
actions.complete();
|
||||
break;
|
||||
}
|
||||
case 'skip-to-license':
|
||||
markStepSeen('tour');
|
||||
actions.updateRuntimeState({ tourRequested: false });
|
||||
actions.complete();
|
||||
break;
|
||||
case 'see-plans':
|
||||
actions.complete();
|
||||
navigate('/settings/adminPlan');
|
||||
break;
|
||||
}
|
||||
}, [actions, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
|
||||
|
||||
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
||||
const [isTourOpen, setIsTourOpen] = useState(false);
|
||||
|
||||
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
|
||||
|
||||
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
||||
const tourOrch = useTourOrchestration();
|
||||
const adminTourOrch = useAdminTourOrchestration();
|
||||
|
||||
const userStepsConfig = useMemo(
|
||||
() => createUserStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveWorkbenchState: tourOrch.saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
backToAllTools: tourOrch.backToAllTools,
|
||||
selectCropTool: tourOrch.selectCropTool,
|
||||
loadSampleFile: tourOrch.loadSampleFile,
|
||||
switchToViewer: tourOrch.switchToViewer,
|
||||
switchToPageEditor: tourOrch.switchToPageEditor,
|
||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||
selectFirstFile: tourOrch.selectFirstFile,
|
||||
pinFile: tourOrch.pinFile,
|
||||
modifyCropSettings: tourOrch.modifyCropSettings,
|
||||
executeTool: tourOrch.executeTool,
|
||||
openFilesModal,
|
||||
},
|
||||
}),
|
||||
[t, tourOrch, closeFilesModal, openFilesModal]
|
||||
);
|
||||
|
||||
const adminStepsConfig = useMemo(
|
||||
() => createAdminStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveAdminState: adminTourOrch.saveAdminState,
|
||||
openConfigModal: adminTourOrch.openConfigModal,
|
||||
navigateToSection: adminTourOrch.navigateToSection,
|
||||
scrollNavToSection: adminTourOrch.scrollNavToSection,
|
||||
},
|
||||
}),
|
||||
[t, adminTourOrch]
|
||||
);
|
||||
|
||||
const tourSteps = useMemo<StepType[]>(() => {
|
||||
const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
|
||||
return Object.values(config);
|
||||
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep?.id === 'tour' && !isTourOpen) {
|
||||
markStepSeen('tour');
|
||||
setIsTourOpen(true);
|
||||
}
|
||||
}, [currentStep, isTourOpen, activeFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalTourRequested) {
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType });
|
||||
markStepSeen('tour');
|
||||
setIsTourOpen(true);
|
||||
clearTourRequest();
|
||||
}
|
||||
}, [externalTourRequested, requestedTourType, actions, clearTourRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTourOpen) removeAllGlows();
|
||||
return () => removeAllGlows();
|
||||
}, [isTourOpen]);
|
||||
|
||||
const finishTour = useCallback(() => {
|
||||
setIsTourOpen(false);
|
||||
if (runtimeState.tourType === 'admin') {
|
||||
adminTourOrch.restoreAdminState();
|
||||
} else {
|
||||
tourOrch.restoreWorkbenchState();
|
||||
}
|
||||
markStepSeen('tour');
|
||||
if (currentStep?.id === 'tour') actions.complete();
|
||||
}, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]);
|
||||
|
||||
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
|
||||
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
|
||||
if (steps && tourCurrentStep === steps.length - 1) {
|
||||
setIsOpen(false);
|
||||
finishTour();
|
||||
} else if (steps) {
|
||||
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
|
||||
}
|
||||
}, [finishTour]);
|
||||
|
||||
const handleCloseTour = useCallback((args: CloseArgs) => {
|
||||
args.setIsOpen(false);
|
||||
finishTour();
|
||||
}, [finishTour]);
|
||||
|
||||
const currentSlideDefinition = useMemo(() => {
|
||||
if (!currentStep || currentStep.type !== 'modal-slide' || !currentStep.slideId) {
|
||||
return null;
|
||||
}
|
||||
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
|
||||
}, [currentStep]);
|
||||
|
||||
const currentSlideContent = useMemo(() => {
|
||||
if (!currentSlideDefinition) return null;
|
||||
return currentSlideDefinition.createSlide({
|
||||
osLabel: osInfo.label,
|
||||
osUrl: osInfo.url,
|
||||
osOptions,
|
||||
onDownloadUrlChange: setSelectedDownloadUrl,
|
||||
selectedRole: runtimeState.selectedRole,
|
||||
onRoleSelect: handleRoleSelect,
|
||||
licenseNotice: runtimeState.licenseNotice,
|
||||
loginEnabled: serverExperience.loginEnabled,
|
||||
firstLoginUsername: runtimeState.firstLoginUsername,
|
||||
onPasswordChanged: handlePasswordChanged,
|
||||
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
|
||||
});
|
||||
}, [currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
|
||||
|
||||
const modalSlideCount = useMemo(() => {
|
||||
return activeFlow.filter((step) => step.type === 'modal-slide').length;
|
||||
}, [activeFlow]);
|
||||
|
||||
const currentModalSlideIndex = useMemo(() => {
|
||||
if (!currentStep || currentStep.type !== 'modal-slide') return 0;
|
||||
const modalSlides = activeFlow.filter((step) => step.type === 'modal-slide');
|
||||
return modalSlides.findIndex((step) => step.id === currentStep.id);
|
||||
}, [activeFlow, currentStep]);
|
||||
|
||||
if (onAuthRoute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (showLicenseSlide) {
|
||||
const slideDefinition = SLIDE_DEFINITIONS['server-license'];
|
||||
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
|
||||
const slideContent = slideDefinition.createSlide({
|
||||
osLabel: '',
|
||||
osUrl: '',
|
||||
osOptions: [],
|
||||
onDownloadUrlChange: () => {},
|
||||
selectedRole: null,
|
||||
onRoleSelect: () => {},
|
||||
licenseNotice: effectiveLicenseNotice,
|
||||
loginEnabled: serverExperience.loginEnabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingModalSlide
|
||||
slideDefinition={slideDefinition}
|
||||
slideContent={slideContent}
|
||||
runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice }}
|
||||
modalSlideCount={1}
|
||||
currentModalSlideIndex={0}
|
||||
onSkip={closeLicenseSlide}
|
||||
onAction={(action) => {
|
||||
if (action === 'see-plans') {
|
||||
closeLicenseSlide();
|
||||
navigate('/settings/adminPlan');
|
||||
} else {
|
||||
closeLicenseSlide();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !isActive || !currentStep) {
|
||||
return (
|
||||
<OnboardingTour
|
||||
isOpen={isTourOpen}
|
||||
tourSteps={tourSteps}
|
||||
tourType={runtimeState.tourType}
|
||||
isRTL={isRTL}
|
||||
t={t}
|
||||
onAdvance={handleAdvanceTour}
|
||||
onClose={handleCloseTour}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentStep.type) {
|
||||
case 'tool-prompt':
|
||||
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
|
||||
|
||||
case 'tour':
|
||||
return (
|
||||
<OnboardingTour
|
||||
isOpen={true}
|
||||
tourSteps={tourSteps}
|
||||
tourType={runtimeState.tourType}
|
||||
isRTL={isRTL}
|
||||
t={t}
|
||||
onAdvance={handleAdvanceTour}
|
||||
onClose={handleCloseTour}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'analytics-modal':
|
||||
return <AdminAnalyticsChoiceModal opened={true} onClose={actions.complete} />;
|
||||
|
||||
case 'modal-slide':
|
||||
if (!currentSlideDefinition || !currentSlideContent) return null;
|
||||
return (
|
||||
<OnboardingModalSlide
|
||||
slideDefinition={currentSlideDefinition}
|
||||
slideContent={currentSlideContent}
|
||||
runtimeState={runtimeState}
|
||||
modalSlideCount={modalSlideCount}
|
||||
currentModalSlideIndex={currentModalSlideIndex}
|
||||
onSkip={actions.skip}
|
||||
onAction={handleButtonAction}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+55
-41
@@ -1,33 +1,44 @@
|
||||
/**
|
||||
* OnboardingModalSlide Component
|
||||
*
|
||||
* Renders a single modal slide in the onboarding flow.
|
||||
* Handles the hero image, content, stepper, and button actions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Stack } from '@mantine/core';
|
||||
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
import type { SlideConfig } from '@app/types/types';
|
||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
|
||||
import { renderButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
import type { InitialOnboardingModalProps } from '@app/components/onboarding/InitialOnboardingModal/types';
|
||||
import { useInitialOnboardingState } from '@app/components/onboarding/InitialOnboardingModal/useInitialOnboardingState';
|
||||
import { SlideButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
export default function InitialOnboardingModal(props: InitialOnboardingModalProps) {
|
||||
const flow = useInitialOnboardingState(props);
|
||||
interface OnboardingModalSlideProps {
|
||||
slideDefinition: SlideDefinition;
|
||||
slideContent: SlideConfig;
|
||||
runtimeState: OnboardingRuntimeState;
|
||||
modalSlideCount: number;
|
||||
currentModalSlideIndex: number;
|
||||
onSkip: () => void;
|
||||
onAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
if (!flow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
state,
|
||||
totalSteps,
|
||||
currentSlide,
|
||||
slideDefinition,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
closeAndMarkSeen,
|
||||
handleButtonAction,
|
||||
} = flow;
|
||||
export default function OnboardingModalSlide({
|
||||
slideDefinition,
|
||||
slideContent,
|
||||
runtimeState,
|
||||
modalSlideCount,
|
||||
currentModalSlideIndex,
|
||||
onSkip,
|
||||
onAction,
|
||||
}: OnboardingModalSlideProps) {
|
||||
|
||||
const renderHero = () => {
|
||||
if (slideDefinition.hero.type === 'dual-icon') {
|
||||
@@ -48,6 +59,9 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
|
||||
{slideDefinition.hero.type === 'shield' && (
|
||||
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'lock' && (
|
||||
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
|
||||
{slideDefinition.hero.type === 'logo' && (
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||
@@ -58,8 +72,8 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={props.opened}
|
||||
onClose={closeAndMarkSeen}
|
||||
opened={true}
|
||||
onClose={onSkip}
|
||||
closeOnClickOutside={false}
|
||||
centered
|
||||
size="lg"
|
||||
@@ -67,48 +81,48 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)' },
|
||||
body: { padding: 0, maxHeight: '90vh', overflow: 'hidden' },
|
||||
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)', maxHeight: '90vh' },
|
||||
}}
|
||||
>
|
||||
<Stack gap={0} className={styles.modalContent}>
|
||||
<div className={styles.heroWrapper}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={currentSlide.background.gradientStops}
|
||||
circles={currentSlide.background.circles}
|
||||
gradientStops={slideContent.background.gradientStops}
|
||||
circles={slideContent.background.circles}
|
||||
isActive
|
||||
slideKey={currentSlide.key}
|
||||
slideKey={slideContent.key}
|
||||
/>
|
||||
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
|
||||
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
|
||||
{renderHero()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.modalBody}>
|
||||
<div className={styles.modalBody} style={{ overflowY: 'auto', maxHeight: 'calc(90vh - 220px)' }}>
|
||||
<Stack gap={16}>
|
||||
<div
|
||||
key={`title-${currentSlide.key}`}
|
||||
key={`title-${slideContent.key}`}
|
||||
className={`${styles.title} ${styles.titleText}`}
|
||||
>
|
||||
{currentSlide.title}
|
||||
{slideContent.title}
|
||||
</div>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{currentSlide.body}
|
||||
<div key={`body-${slideContent.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{slideContent.body}
|
||||
</div>
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={state.step} />
|
||||
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
{renderButtons({
|
||||
slideDefinition,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
onAction: handleButtonAction,
|
||||
})}
|
||||
<SlideButtons
|
||||
slideDefinition={slideDefinition}
|
||||
licenseNotice={runtimeState.licenseNotice}
|
||||
flowState={{ selectedRole: runtimeState.selectedRole }}
|
||||
onAction={onAction}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
@@ -1,231 +1,151 @@
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { TourProvider, type StepType } from '@reactour/tour';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
/**
|
||||
* OnboardingTour Component
|
||||
*
|
||||
* Reusable tour wrapper that encapsulates all Reactour configuration.
|
||||
* Used by the main Onboarding component for both the 'tour' step and
|
||||
* when the tour is open but onboarding is inactive.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { TourProvider, useTour, type StepType } from '@reactour/tour';
|
||||
import { CloseButton, ActionIcon } from '@mantine/core';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal';
|
||||
import ServerLicenseModal from '@app/components/onboarding/ServerLicenseModal';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
||||
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
||||
import { useOnboardingFlow } from '@app/components/onboarding/hooks/useOnboardingFlow';
|
||||
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
|
||||
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
|
||||
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||
import TourContent from '@app/components/onboarding/TourContent';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
import i18n from "@app/i18n";
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import type { TFunction } from 'i18next';
|
||||
import i18n from '@app/i18n';
|
||||
|
||||
export default function OnboardingTour() {
|
||||
const { t } = useTranslation();
|
||||
const flow = useOnboardingFlow();
|
||||
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
restoreWorkbenchState,
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
} = useTourOrchestration();
|
||||
const {
|
||||
saveAdminState,
|
||||
restoreAdminState,
|
||||
openConfigModal,
|
||||
navigateToSection,
|
||||
scrollNavToSection,
|
||||
} = useAdminTourOrchestration();
|
||||
/**
|
||||
* TourContent - Controls the tour visibility
|
||||
* Syncs the forceOpen prop with the reactour tour state.
|
||||
*/
|
||||
function TourContent({ forceOpen = false }: { forceOpen?: boolean }) {
|
||||
const { setIsOpen, setCurrentStep } = useTour();
|
||||
const previousIsOpenRef = React.useRef(forceOpen);
|
||||
|
||||
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
||||
React.useEffect(() => {
|
||||
const wasClosedNowOpen = !previousIsOpenRef.current && forceOpen;
|
||||
previousIsOpenRef.current = forceOpen;
|
||||
|
||||
useEffect(() => {
|
||||
if (!flow.isTourOpen) {
|
||||
removeAllGlows();
|
||||
if (wasClosedNowOpen) {
|
||||
setCurrentStep(0);
|
||||
}
|
||||
return () => removeAllGlows();
|
||||
}, [flow.isTourOpen]);
|
||||
setIsOpen(forceOpen);
|
||||
}, [forceOpen, setIsOpen, setCurrentStep]);
|
||||
|
||||
const userStepsConfig = useMemo(
|
||||
() =>
|
||||
createUserStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
openFilesModal,
|
||||
},
|
||||
}),
|
||||
[
|
||||
t,
|
||||
backToAllTools,
|
||||
closeFilesModal,
|
||||
executeTool,
|
||||
loadSampleFile,
|
||||
modifyCropSettings,
|
||||
openFilesModal,
|
||||
pinFile,
|
||||
saveWorkbenchState,
|
||||
selectCropTool,
|
||||
selectFirstFile,
|
||||
switchToActiveFiles,
|
||||
switchToPageEditor,
|
||||
switchToViewer,
|
||||
],
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const adminStepsConfig = useMemo(
|
||||
() =>
|
||||
createAdminStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveAdminState,
|
||||
openConfigModal,
|
||||
navigateToSection,
|
||||
scrollNavToSection,
|
||||
},
|
||||
}),
|
||||
[navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t],
|
||||
);
|
||||
interface AdvanceArgs {
|
||||
setCurrentStep: (value: number | ((prev: number) => number)) => void;
|
||||
currentStep: number;
|
||||
steps?: StepType[];
|
||||
setIsOpen: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const steps = useMemo<StepType[]>(() => {
|
||||
const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
|
||||
return Object.values(config);
|
||||
}, [adminStepsConfig, flow.tourType, userStepsConfig]);
|
||||
interface CloseArgs {
|
||||
setIsOpen: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const advanceTour = ({
|
||||
setCurrentStep,
|
||||
currentStep,
|
||||
steps,
|
||||
setIsOpen,
|
||||
}: {
|
||||
setCurrentStep: (value: number | ((prev: number) => number)) => void;
|
||||
currentStep: number;
|
||||
steps?: StepType[];
|
||||
setIsOpen: (value: boolean) => void;
|
||||
}) => {
|
||||
if (steps && currentStep === steps.length - 1) {
|
||||
setIsOpen(false);
|
||||
if (flow.tourType === 'admin') {
|
||||
restoreAdminState();
|
||||
} else {
|
||||
restoreWorkbenchState();
|
||||
}
|
||||
flow.handleTourCompletion();
|
||||
} else if (steps) {
|
||||
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
|
||||
}
|
||||
};
|
||||
interface OnboardingTourProps {
|
||||
tourSteps: StepType[];
|
||||
tourType: 'admin' | 'tools';
|
||||
isRTL: boolean;
|
||||
t: TFunction;
|
||||
isOpen: boolean;
|
||||
onAdvance: (args: AdvanceArgs) => void;
|
||||
onClose: (args: CloseArgs) => void;
|
||||
}
|
||||
|
||||
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
|
||||
setIsOpen(false);
|
||||
if (flow.tourType === 'admin') {
|
||||
restoreAdminState();
|
||||
} else {
|
||||
restoreWorkbenchState();
|
||||
}
|
||||
flow.handleTourCompletion();
|
||||
};
|
||||
export default function OnboardingTour({
|
||||
tourSteps,
|
||||
tourType,
|
||||
isRTL,
|
||||
t,
|
||||
isOpen,
|
||||
onAdvance,
|
||||
onClose,
|
||||
}: OnboardingTourProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<InitialOnboardingModal {...flow.initialModalProps} />
|
||||
<ToolPanelModePrompt onComplete={flow.handleToolPromptComplete} />
|
||||
<TourProvider
|
||||
key={`${flow.tourType}-${i18n.language}`}
|
||||
steps={steps}
|
||||
maskClassName={flow.maskClassName}
|
||||
onClickClose={handleCloseTour}
|
||||
onClickMask={advanceTour}
|
||||
onClickHighlighted={(e, clickProps) => {
|
||||
e.stopPropagation();
|
||||
advanceTour(clickProps);
|
||||
}}
|
||||
keyboardHandler={(e, clickProps, status) => {
|
||||
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
advanceTour(clickProps);
|
||||
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
handleCloseTour(clickProps);
|
||||
}
|
||||
}}
|
||||
rtl={isRTL}
|
||||
styles={{
|
||||
popover: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
maxWidth: '400px',
|
||||
}),
|
||||
maskArea: (base) => ({
|
||||
...base,
|
||||
rx: 8,
|
||||
}),
|
||||
badge: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-primary-color-filled)',
|
||||
}),
|
||||
controls: (base) => ({
|
||||
...base,
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
}}
|
||||
highlightedMaskClassName="tour-highlight-glow"
|
||||
showNavigation={true}
|
||||
showBadge={false}
|
||||
showCloseButton={true}
|
||||
disableInteraction={true}
|
||||
disableDotsNavigation={false}
|
||||
prevButton={() => null}
|
||||
nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
||||
const isLast = currentStep === stepsLength - 1;
|
||||
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() => advanceTour({ setCurrentStep, currentStep, steps, setIsOpen })}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
|
||||
>
|
||||
{isLast ? <CheckIcon /> : <ArrowIcon />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}}
|
||||
components={{
|
||||
Close: ({ onClick }) => (
|
||||
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
|
||||
),
|
||||
Content: ({ content }: { content: string }) => (
|
||||
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
<TourContent />
|
||||
</TourProvider>
|
||||
<ServerLicenseModal {...flow.serverLicenseModalProps} />
|
||||
</>
|
||||
<TourProvider
|
||||
key={`${tourType}-${i18n.language}`}
|
||||
steps={tourSteps}
|
||||
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
|
||||
onClickClose={onClose}
|
||||
onClickMask={onAdvance}
|
||||
onClickHighlighted={(e, clickProps) => {
|
||||
e.stopPropagation();
|
||||
onAdvance(clickProps);
|
||||
}}
|
||||
keyboardHandler={(e, clickProps, status) => {
|
||||
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
onAdvance(clickProps);
|
||||
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
onClose(clickProps);
|
||||
}
|
||||
}}
|
||||
rtl={isRTL}
|
||||
styles={{
|
||||
popover: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
maxWidth: '400px',
|
||||
}),
|
||||
maskArea: (base) => ({
|
||||
...base,
|
||||
rx: 8,
|
||||
}),
|
||||
badge: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-primary-color-filled)',
|
||||
}),
|
||||
controls: (base) => ({
|
||||
...base,
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
}}
|
||||
highlightedMaskClassName="tour-highlight-glow"
|
||||
showNavigation={true}
|
||||
showBadge={false}
|
||||
showCloseButton={true}
|
||||
disableInteraction={true}
|
||||
disableDotsNavigation={false}
|
||||
prevButton={() => null}
|
||||
nextButton={({ currentStep: tourCurrentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
||||
const isLast = tourCurrentStep === stepsLength - 1;
|
||||
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
|
||||
>
|
||||
{isLast ? <CheckIcon /> : <ArrowIcon />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}}
|
||||
components={{
|
||||
Close: ({ onClick }) => (
|
||||
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
|
||||
),
|
||||
Content: ({ content }: { content: string }) => (
|
||||
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
<TourContent forceOpen={true} />
|
||||
</TourProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export type { AdvanceArgs, CloseArgs };
|
||||
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Modal, Button, Group, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
||||
import { LicenseNotice } from '@app/types/types';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
interface ServerLicenseModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSeePlans?: () => void;
|
||||
licenseNotice: LicenseNotice;
|
||||
}
|
||||
|
||||
export default function ServerLicenseModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSeePlans,
|
||||
licenseNotice,
|
||||
}: ServerLicenseModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const slide = React.useMemo(() => ServerLicenseSlide({ licenseNotice }), [licenseNotice]);
|
||||
const primaryLabel = licenseNotice.isOverLimit
|
||||
? t('onboarding.serverLicense.upgrade', 'Upgrade now →')
|
||||
: t('onboarding.serverLicense.seePlans', 'See Plans →');
|
||||
const secondaryLabel = t('onboarding.serverLicense.skip', 'Skip for now');
|
||||
|
||||
const handleSeePlans = () => {
|
||||
onSeePlans?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const secondaryStyles = {
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
},
|
||||
};
|
||||
|
||||
const primaryStyles = {
|
||||
root: {
|
||||
background: 'var(--onboarding-primary-button-bg)',
|
||||
color: 'var(--onboarding-primary-button-text)',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)' },
|
||||
}}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<div className={styles.heroWrapper}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={slide.background.gradientStops}
|
||||
circles={slide.background.circles}
|
||||
isActive
|
||||
slideKey={slide.key}
|
||||
/>
|
||||
<div className={styles.heroLogo}>
|
||||
<div className={styles.heroIconsContainer}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 24 }}>
|
||||
<Stack gap={16}>
|
||||
<div
|
||||
className={styles.title}
|
||||
style={{
|
||||
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
|
||||
fontWeight: 600,
|
||||
fontSize: 22,
|
||||
color: 'var(--onboarding-title)',
|
||||
}}
|
||||
>
|
||||
{slide.title}
|
||||
</div>
|
||||
<div
|
||||
className={styles.bodyCopy}
|
||||
style={{
|
||||
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
|
||||
fontSize: 16,
|
||||
color: 'var(--onboarding-body)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{slide.body}
|
||||
</div>
|
||||
<Group justify="space-between">
|
||||
<Button styles={secondaryStyles} onClick={onClose}>
|
||||
{secondaryLabel}
|
||||
</Button>
|
||||
<Button styles={primaryStyles} onClick={handleSeePlans}>
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTour } from '@reactour/tour';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
|
||||
export default function TourContent() {
|
||||
const { isOpen } = useOnboarding();
|
||||
const { setIsOpen, setCurrentStep } = useTour();
|
||||
const previousIsOpenRef = React.useRef(isOpen);
|
||||
|
||||
React.useEffect(() => {
|
||||
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
|
||||
previousIsOpenRef.current = isOpen;
|
||||
|
||||
if (wasClosedNowOpen) {
|
||||
setCurrentStep(0);
|
||||
}
|
||||
setIsOpen(isOpen);
|
||||
}, [isOpen, setIsOpen, setCurrentStep]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Modal, Title, Text, Button, Stack, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
|
||||
interface TourWelcomeModalProps {
|
||||
opened: boolean;
|
||||
onStartTour: () => void;
|
||||
onMaybeLater: () => void;
|
||||
onDontShowAgain: () => void;
|
||||
}
|
||||
|
||||
export default function TourWelcomeModal({
|
||||
opened,
|
||||
onStartTour,
|
||||
onMaybeLater,
|
||||
onDontShowAgain,
|
||||
}: TourWelcomeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onMaybeLater}
|
||||
centered
|
||||
size="md"
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Stack gap="xs">
|
||||
<Title order={2}>
|
||||
{t('onboarding.welcomeModal.title', 'Welcome to Stirling PDF!')}
|
||||
</Title>
|
||||
<Text size="md" c="dimmed">
|
||||
{t('onboarding.welcomeModal.description',
|
||||
"Would you like to take a quick 1-minute tour to learn the key features and how to get started?"
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
size="md"
|
||||
c="dimmed"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('onboarding.welcomeModal.helpHint',
|
||||
'You can always access this tour later from the <strong>Help</strong> button in the bottom left.'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
onClick={onStartTour}
|
||||
size="md"
|
||||
variant="filled"
|
||||
fullWidth
|
||||
>
|
||||
{t('onboarding.welcomeModal.startTour', 'Start Tour')}
|
||||
</Button>
|
||||
|
||||
<Group grow>
|
||||
<Button
|
||||
onClick={onMaybeLater}
|
||||
size="md"
|
||||
variant="light"
|
||||
>
|
||||
{t('onboarding.welcomeModal.maybeLater', 'Maybe Later')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onDontShowAgain}
|
||||
size="md"
|
||||
variant="light"
|
||||
>
|
||||
{t('onboarding.welcomeModal.dontShowAgain', "Don't Show Again")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { StepType } from '@reactour/tour';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { AdminTourStep } from '@app/components/onboarding/tourSteps';
|
||||
import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||
|
||||
export enum AdminTourStep {
|
||||
WELCOME,
|
||||
CONFIG_BUTTON,
|
||||
SETTINGS_OVERVIEW,
|
||||
TEAMS_AND_USERS,
|
||||
SYSTEM_CUSTOMIZATION,
|
||||
DATABASE_SECTION,
|
||||
CONNECTIONS_SECTION,
|
||||
ADMIN_TOOLS,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
interface AdminStepActions {
|
||||
saveAdminState: () => void;
|
||||
openConfigModal: () => void;
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
ONBOARDING_SESSION_BLOCK_KEY,
|
||||
ONBOARDING_SESSION_EVENT,
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
type ServerLicenseRequestPayload,
|
||||
} from '@app/constants/events';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
|
||||
// Auth routes where onboarding should NOT show
|
||||
const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];
|
||||
|
||||
// Check if user has an auth token (to avoid flash before redirect)
|
||||
function hasAuthToken(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return !!localStorage.getItem('stirling_jwt');
|
||||
}
|
||||
|
||||
interface InitialModalHandlers {
|
||||
opened: boolean;
|
||||
onLicenseNoticeUpdate: (notice: LicenseNotice) => void;
|
||||
onRequestServerLicense: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ServerLicenseModalHandlers {
|
||||
opened: boolean;
|
||||
licenseNotice: LicenseNotice;
|
||||
onClose: () => void;
|
||||
onSeePlans: () => void;
|
||||
}
|
||||
|
||||
export function useOnboardingFlow() {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config, loading: configLoading } = useAppConfig();
|
||||
const { completeTour, tourType, isOpen } = useOnboarding();
|
||||
const location = useLocation();
|
||||
|
||||
// Check if we're on an auth route (login, signup, etc.)
|
||||
const isOnAuthRoute = AUTH_ROUTES.some(route => location.pathname.startsWith(route));
|
||||
|
||||
// Check if login is enabled but user doesn't have a token
|
||||
// This prevents a flash of the modal before redirect to /login
|
||||
const loginEnabled = config?.enableLogin === true;
|
||||
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
|
||||
|
||||
// Don't show intro onboarding:
|
||||
// 1. On explicit auth routes (/login, /signup, etc.)
|
||||
// 2. While config is still loading
|
||||
// 3. When login is enabled but user isn't authenticated (would redirect to /login)
|
||||
// This ensures:
|
||||
// - If login is enabled: user must be logged in before seeing onboarding
|
||||
// - If login is disabled: homepage must have rendered first
|
||||
const shouldShowIntro = !preferences.hasSeenIntroOnboarding
|
||||
&& !isOnAuthRoute
|
||||
&& !configLoading
|
||||
&& !isUnauthenticatedWithLoginEnabled;
|
||||
const isAdminUser = !!config?.isAdmin;
|
||||
const { hasPaidLicense } = useServerExperience();
|
||||
|
||||
const [licenseNotice, setLicenseNotice] = useState<LicenseNotice>({
|
||||
totalUsers: null,
|
||||
freeTierLimit: 5,
|
||||
isOverLimit: false,
|
||||
requiresLicense: false,
|
||||
});
|
||||
const [serverLicenseIntent, setServerLicenseIntent] = useState<'idle' | 'pending' | 'deferred'>('idle');
|
||||
const [serverLicenseSource, setServerLicenseSource] = useState<'config' | 'self-reported' | null>(null);
|
||||
const [isServerLicenseOpen, setIsServerLicenseOpen] = useState(false);
|
||||
const [hasShownServerLicense, setHasShownServerLicense] = useState(false);
|
||||
const [toolPromptCompleted, setToolPromptCompleted] = useState(
|
||||
preferences.toolPanelModePromptSeen || preferences.hasSelectedToolPanelMode,
|
||||
);
|
||||
const introWasOpenRef = useRef(false);
|
||||
const navigate = useNavigate();
|
||||
const onboardingSessionMarkedRef = useRef(false);
|
||||
|
||||
const handleInitialModalClose = useCallback(() => {
|
||||
if (!preferences.hasSeenIntroOnboarding) {
|
||||
updatePreference('hasSeenIntroOnboarding', true);
|
||||
}
|
||||
}, [preferences.hasSeenIntroOnboarding, updatePreference]);
|
||||
|
||||
const handleLicenseNoticeUpdate = useCallback((notice: LicenseNotice) => {
|
||||
setLicenseNotice(notice);
|
||||
}, []);
|
||||
|
||||
const handleToolPromptComplete = useCallback(() => {
|
||||
setToolPromptCompleted(true);
|
||||
}, []);
|
||||
|
||||
const requestServerLicense = useCallback(
|
||||
({
|
||||
deferUntilTourComplete = false,
|
||||
selfReportedAdmin = false,
|
||||
}: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean } = {}) => {
|
||||
const qualifies = isAdminUser || selfReportedAdmin;
|
||||
if (!qualifies) {
|
||||
return;
|
||||
}
|
||||
if (hasPaidLicense || !licenseNotice.requiresLicense) {
|
||||
return;
|
||||
}
|
||||
setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
|
||||
setServerLicenseIntent((prev) => {
|
||||
if (prev === 'pending') {
|
||||
return prev;
|
||||
}
|
||||
if (prev === 'deferred') {
|
||||
return deferUntilTourComplete ? prev : 'pending';
|
||||
}
|
||||
if (prev === 'idle') {
|
||||
return deferUntilTourComplete ? 'deferred' : 'pending';
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[hasPaidLicense, isAdminUser, licenseNotice.requiresLicense],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleServerLicenseRequested = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
|
||||
|
||||
if (detail?.licenseNotice) {
|
||||
setLicenseNotice((prev) => ({
|
||||
...prev,
|
||||
...detail.licenseNotice,
|
||||
totalUsers:
|
||||
detail.licenseNotice?.totalUsers ?? prev.totalUsers,
|
||||
freeTierLimit:
|
||||
detail.licenseNotice?.freeTierLimit ?? prev.freeTierLimit,
|
||||
isOverLimit:
|
||||
detail.licenseNotice?.isOverLimit ?? prev.isOverLimit,
|
||||
requiresLicense:
|
||||
detail.licenseNotice?.requiresLicense ?? prev.requiresLicense,
|
||||
}));
|
||||
}
|
||||
|
||||
requestServerLicense({
|
||||
deferUntilTourComplete: detail?.deferUntilTourComplete ?? false,
|
||||
selfReportedAdmin: detail?.selfReportedAdmin ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
handleServerLicenseRequested as EventListener,
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
handleServerLicenseRequested as EventListener,
|
||||
);
|
||||
};
|
||||
}, [requestServerLicense]);
|
||||
|
||||
useEffect(() => {
|
||||
const isEligibleAdmin =
|
||||
isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
|
||||
if (
|
||||
introWasOpenRef.current &&
|
||||
!shouldShowIntro &&
|
||||
isEligibleAdmin &&
|
||||
toolPromptCompleted &&
|
||||
!hasShownServerLicense &&
|
||||
licenseNotice.requiresLicense &&
|
||||
serverLicenseIntent === 'idle'
|
||||
) {
|
||||
if (!serverLicenseSource) {
|
||||
setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
|
||||
}
|
||||
setServerLicenseIntent('pending');
|
||||
}
|
||||
introWasOpenRef.current = shouldShowIntro;
|
||||
}, [
|
||||
hasShownServerLicense,
|
||||
isAdminUser,
|
||||
serverLicenseIntent,
|
||||
shouldShowIntro,
|
||||
serverLicenseSource,
|
||||
toolPromptCompleted,
|
||||
licenseNotice.requiresLicense,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const isEligibleAdmin =
|
||||
isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
|
||||
if (
|
||||
serverLicenseIntent !== 'idle' &&
|
||||
!shouldShowIntro &&
|
||||
!isOpen &&
|
||||
!isServerLicenseOpen &&
|
||||
isEligibleAdmin &&
|
||||
toolPromptCompleted &&
|
||||
licenseNotice.requiresLicense
|
||||
) {
|
||||
setIsServerLicenseOpen(true);
|
||||
setServerLicenseIntent(serverLicenseIntent === 'deferred' ? 'pending' : 'idle');
|
||||
}
|
||||
}, [
|
||||
isAdminUser,
|
||||
isOpen,
|
||||
isServerLicenseOpen,
|
||||
serverLicenseIntent,
|
||||
shouldShowIntro,
|
||||
serverLicenseSource,
|
||||
toolPromptCompleted,
|
||||
licenseNotice.requiresLicense,
|
||||
]);
|
||||
|
||||
const handleServerLicenseClose = useCallback(() => {
|
||||
setIsServerLicenseOpen(false);
|
||||
setHasShownServerLicense(true);
|
||||
setServerLicenseIntent('idle');
|
||||
setServerLicenseSource(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingSessionMarkedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (shouldShowIntro || isOpen) {
|
||||
onboardingSessionMarkedRef.current = true;
|
||||
window.sessionStorage.setItem(ONBOARDING_SESSION_BLOCK_KEY, 'true');
|
||||
window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
|
||||
}
|
||||
}, [isOpen, shouldShowIntro]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!shouldShowIntro && !isOpen) {
|
||||
window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
|
||||
window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
|
||||
}
|
||||
}, [isOpen, shouldShowIntro]);
|
||||
|
||||
const handleServerLicenseSeePlans = useCallback(() => {
|
||||
handleServerLicenseClose();
|
||||
navigate('/settings/adminPlan');
|
||||
}, [handleServerLicenseClose, navigate]);
|
||||
|
||||
const handleTourCompletion = useCallback(() => {
|
||||
completeTour();
|
||||
if (serverLicenseIntent === 'deferred') {
|
||||
setServerLicenseIntent('pending');
|
||||
} else if (tourType === 'admin' && (isAdminUser || serverLicenseSource === 'self-reported')) {
|
||||
setServerLicenseSource((prev) => prev ?? (isAdminUser ? 'config' : 'self-reported'));
|
||||
setServerLicenseIntent((prev) => (prev === 'pending' ? prev : 'pending'));
|
||||
}
|
||||
}, [
|
||||
completeTour,
|
||||
isAdminUser,
|
||||
serverLicenseIntent,
|
||||
serverLicenseSource,
|
||||
tourType,
|
||||
]);
|
||||
|
||||
const initialModalProps: InitialModalHandlers = useMemo(
|
||||
() => ({
|
||||
opened: shouldShowIntro,
|
||||
onLicenseNoticeUpdate: handleLicenseNoticeUpdate,
|
||||
onRequestServerLicense: requestServerLicense,
|
||||
onClose: handleInitialModalClose,
|
||||
}),
|
||||
[handleInitialModalClose, handleLicenseNoticeUpdate, requestServerLicense, shouldShowIntro],
|
||||
);
|
||||
|
||||
const serverLicenseModalProps: ServerLicenseModalHandlers = useMemo(
|
||||
() => ({
|
||||
opened: isServerLicenseOpen,
|
||||
licenseNotice,
|
||||
onClose: handleServerLicenseClose,
|
||||
onSeePlans: handleServerLicenseSeePlans,
|
||||
}),
|
||||
[handleServerLicenseClose, handleServerLicenseSeePlans, isServerLicenseOpen, licenseNotice],
|
||||
);
|
||||
|
||||
return {
|
||||
tourType,
|
||||
isTourOpen: isOpen,
|
||||
maskClassName: tourType === 'admin' ? 'admin-tour-mask' : undefined,
|
||||
initialModalProps,
|
||||
handleToolPromptComplete,
|
||||
serverLicenseModalProps,
|
||||
handleTourCompletion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,16 +3,18 @@ import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstal
|
||||
import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide';
|
||||
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
||||
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
|
||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
||||
|
||||
export type SlideId =
|
||||
| 'first-login'
|
||||
| 'welcome'
|
||||
| 'desktop-install'
|
||||
| 'security-check'
|
||||
| 'admin-overview'
|
||||
| 'server-license';
|
||||
|
||||
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo';
|
||||
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock';
|
||||
|
||||
export type ButtonAction =
|
||||
| 'next'
|
||||
@@ -46,6 +48,10 @@ export interface SlideFactoryParams {
|
||||
onRoleSelect: (role: 'admin' | 'user' | null) => void;
|
||||
licenseNotice?: LicenseNotice;
|
||||
loginEnabled?: boolean;
|
||||
// First login params
|
||||
firstLoginUsername?: string;
|
||||
onPasswordChanged?: () => void;
|
||||
usingDefaultCredentials?: boolean;
|
||||
}
|
||||
|
||||
export interface HeroDefinition {
|
||||
@@ -71,6 +77,17 @@ export interface SlideDefinition {
|
||||
}
|
||||
|
||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
'first-login': {
|
||||
id: 'first-login',
|
||||
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
||||
FirstLoginSlide({
|
||||
username: firstLoginUsername || '',
|
||||
onPasswordChanged: onPasswordChanged || (() => {}),
|
||||
usingDefaultCredentials: usingDefaultCredentials || false,
|
||||
}),
|
||||
hero: { type: 'lock' },
|
||||
buttons: [], // Form has its own submit button
|
||||
},
|
||||
'welcome': {
|
||||
id: 'welcome',
|
||||
createSlide: () => WelcomeSlide(),
|
||||
@@ -197,11 +214,3 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const FLOW_SEQUENCES = {
|
||||
loginAdmin: ['welcome', 'desktop-install', 'admin-overview'] as SlideId[],
|
||||
loginUser: ['welcome', 'desktop-install'] as SlideId[],
|
||||
noLoginBase: ['welcome', 'desktop-install', 'security-check'] as SlideId[],
|
||||
noLoginAdmin: ['admin-overview'] as SlideId[],
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
export type OnboardingStepId =
|
||||
| 'first-login'
|
||||
| 'welcome'
|
||||
| 'desktop-install'
|
||||
| 'security-check'
|
||||
| 'admin-overview'
|
||||
| 'tool-layout'
|
||||
| 'tour'
|
||||
| 'server-license'
|
||||
| 'analytics-choice';
|
||||
|
||||
export type OnboardingStepType =
|
||||
| 'modal-slide'
|
||||
| 'tool-prompt'
|
||||
| 'tour'
|
||||
| 'analytics-modal';
|
||||
|
||||
export interface OnboardingRuntimeState {
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
tourRequested: boolean;
|
||||
tourType: 'admin' | 'tools';
|
||||
isDesktopApp: boolean;
|
||||
analyticsNotConfigured: boolean;
|
||||
analyticsEnabled: boolean;
|
||||
licenseNotice: {
|
||||
totalUsers: number | null;
|
||||
freeTierLimit: number;
|
||||
isOverLimit: boolean;
|
||||
requiresLicense: boolean;
|
||||
};
|
||||
requiresPasswordChange: boolean;
|
||||
firstLoginUsername: string;
|
||||
usingDefaultCredentials: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingConditionContext extends OnboardingRuntimeState {
|
||||
loginEnabled: boolean;
|
||||
effectiveIsAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingStep {
|
||||
id: OnboardingStepId;
|
||||
type: OnboardingStepType;
|
||||
condition: (ctx: OnboardingConditionContext) => boolean;
|
||||
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license';
|
||||
}
|
||||
|
||||
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
||||
selectedRole: null,
|
||||
tourRequested: false,
|
||||
tourType: 'tools',
|
||||
isDesktopApp: false,
|
||||
analyticsNotConfigured: false,
|
||||
analyticsEnabled: false,
|
||||
licenseNotice: {
|
||||
totalUsers: null,
|
||||
freeTierLimit: 5,
|
||||
isOverLimit: false,
|
||||
requiresLicense: false,
|
||||
},
|
||||
requiresPasswordChange: false,
|
||||
firstLoginUsername: '',
|
||||
usingDefaultCredentials: false,
|
||||
};
|
||||
|
||||
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
{
|
||||
id: 'first-login',
|
||||
type: 'modal-slide',
|
||||
slideId: 'first-login',
|
||||
condition: (ctx) => ctx.requiresPasswordChange,
|
||||
},
|
||||
{
|
||||
id: 'welcome',
|
||||
type: 'modal-slide',
|
||||
slideId: 'welcome',
|
||||
condition: () => true,
|
||||
},
|
||||
{
|
||||
id: 'desktop-install',
|
||||
type: 'modal-slide',
|
||||
slideId: 'desktop-install',
|
||||
condition: (ctx) => !ctx.isDesktopApp,
|
||||
},
|
||||
{
|
||||
id: 'security-check',
|
||||
type: 'modal-slide',
|
||||
slideId: 'security-check',
|
||||
condition: (ctx) => !ctx.loginEnabled && !ctx.isDesktopApp,
|
||||
},
|
||||
{
|
||||
id: 'admin-overview',
|
||||
type: 'modal-slide',
|
||||
slideId: 'admin-overview',
|
||||
condition: (ctx) => ctx.effectiveIsAdmin,
|
||||
},
|
||||
{
|
||||
id: 'tool-layout',
|
||||
type: 'tool-prompt',
|
||||
condition: () => true,
|
||||
},
|
||||
{
|
||||
id: 'tour',
|
||||
type: 'tour',
|
||||
condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin,
|
||||
},
|
||||
{
|
||||
id: 'server-license',
|
||||
type: 'modal-slide',
|
||||
slideId: 'server-license',
|
||||
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
|
||||
},
|
||||
{
|
||||
id: 'analytics-choice',
|
||||
type: 'analytics-modal',
|
||||
condition: (ctx) => ctx.effectiveIsAdmin && ctx.analyticsNotConfigured,
|
||||
},
|
||||
];
|
||||
|
||||
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
|
||||
return ONBOARDING_STEPS.find((step) => step.id === id);
|
||||
}
|
||||
|
||||
export function getStepIndex(id: OnboardingStepId): number {
|
||||
return ONBOARDING_STEPS.findIndex((step) => step.id === id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { type OnboardingStepId, ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
|
||||
const STORAGE_PREFIX = 'onboarding';
|
||||
|
||||
export function getStorageKey(stepId: OnboardingStepId): string {
|
||||
return `${STORAGE_PREFIX}::${stepId}`;
|
||||
}
|
||||
|
||||
export function hasSeenStep(stepId: OnboardingStepId): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return localStorage.getItem(getStorageKey(stepId)) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function markStepSeen(stepId: OnboardingStepId): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(stepId), 'true');
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error marking step as seen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetStepSeen(stepId: OnboardingStepId): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.removeItem(getStorageKey(stepId));
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error resetting step seen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetAllOnboardingProgress(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const prefix = `${STORAGE_PREFIX}::`;
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(prefix)) keysToRemove.push(key);
|
||||
}
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error resetting all onboarding progress:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getOnboardingStorageState(): Record<string, boolean> {
|
||||
const state: Record<string, boolean> = {};
|
||||
ONBOARDING_STEPS.forEach((step) => {
|
||||
state[step.id] = hasSeenStep(step.id);
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
export function migrateFromLegacyPreferences(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const migrationKey = `${STORAGE_PREFIX}::migrated`;
|
||||
|
||||
try {
|
||||
// Skip if already migrated
|
||||
if (localStorage.getItem(migrationKey) === 'true') return;
|
||||
|
||||
const prefsRaw = localStorage.getItem('stirlingpdf_preferences');
|
||||
if (prefsRaw) {
|
||||
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
|
||||
|
||||
// Migrate based on legacy flags
|
||||
if (prefs.hasSeenIntroOnboarding === true) {
|
||||
markStepSeen('welcome');
|
||||
markStepSeen('desktop-install');
|
||||
markStepSeen('security-check');
|
||||
markStepSeen('admin-overview');
|
||||
}
|
||||
|
||||
if (prefs.toolPanelModePromptSeen === true || prefs.hasSelectedToolPanelMode === true) {
|
||||
markStepSeen('tool-layout');
|
||||
}
|
||||
|
||||
if (prefs.hasCompletedOnboarding === true) {
|
||||
markStepSeen('tour');
|
||||
markStepSeen('analytics-choice');
|
||||
markStepSeen('server-license');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Mark migration complete
|
||||
localStorage.setItem(migrationKey, 'true');
|
||||
} catch {
|
||||
// If migration fails, onboarding will show again - safer than hiding it
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
import {
|
||||
ONBOARDING_STEPS,
|
||||
type OnboardingStepId,
|
||||
type OnboardingStep,
|
||||
type OnboardingRuntimeState,
|
||||
type OnboardingConditionContext,
|
||||
DEFAULT_RUNTIME_STATE,
|
||||
} from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
import {
|
||||
hasSeenStep,
|
||||
markStepSeen,
|
||||
migrateFromLegacyPreferences,
|
||||
} from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
import { accountService } from '@app/services/accountService';
|
||||
|
||||
const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];
|
||||
const SESSION_TOUR_REQUESTED = 'onboarding::session::tour-requested';
|
||||
const SESSION_TOUR_TYPE = 'onboarding::session::tour-type';
|
||||
const SESSION_SELECTED_ROLE = 'onboarding::session::selected-role';
|
||||
|
||||
// Check if user has an auth token (to avoid flash before redirect)
|
||||
function hasAuthToken(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return !!localStorage.getItem('stirling_jwt');
|
||||
}
|
||||
|
||||
// Get initial runtime state from session storage (survives remounts)
|
||||
function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState {
|
||||
if (typeof window === 'undefined') {
|
||||
return baseState;
|
||||
}
|
||||
|
||||
try {
|
||||
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true';
|
||||
const tourType = (sessionStorage.getItem(SESSION_TOUR_TYPE) as 'admin' | 'tools') || 'tools';
|
||||
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
tourRequested,
|
||||
tourType,
|
||||
selectedRole,
|
||||
};
|
||||
} catch {
|
||||
return baseState;
|
||||
}
|
||||
}
|
||||
|
||||
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
if (state.tourRequested !== undefined) {
|
||||
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false');
|
||||
}
|
||||
if (state.tourType !== undefined) {
|
||||
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
|
||||
}
|
||||
if (state.selectedRole !== undefined) {
|
||||
if (state.selectedRole) {
|
||||
sessionStorage.setItem(SESSION_SELECTED_ROLE, state.selectedRole);
|
||||
} else {
|
||||
sessionStorage.removeItem(SESSION_SELECTED_ROLE);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[useOnboardingOrchestrator] Error persisting runtime state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function clearRuntimeStateSession(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
|
||||
sessionStorage.removeItem(SESSION_TOUR_TYPE);
|
||||
sessionStorage.removeItem(SESSION_SELECTED_ROLE);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
export interface OnboardingOrchestratorState {
|
||||
/** Whether onboarding is currently active */
|
||||
isActive: boolean;
|
||||
/** The current step being shown (null if no step is active) */
|
||||
currentStep: OnboardingStep | null;
|
||||
/** Index of current step in the active flow (for display purposes) */
|
||||
currentStepIndex: number;
|
||||
/** Total number of steps in the active flow */
|
||||
totalSteps: number;
|
||||
/** Runtime state that affects conditions */
|
||||
runtimeState: OnboardingRuntimeState;
|
||||
/** All steps that will be shown in this flow (filtered by conditions) */
|
||||
activeFlow: OnboardingStep[];
|
||||
/** Whether all steps have been seen */
|
||||
isComplete: boolean;
|
||||
/** Whether we're still initializing */
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingOrchestratorActions {
|
||||
/** Move to the next step */
|
||||
next: () => void;
|
||||
/** Move to the previous step */
|
||||
prev: () => void;
|
||||
/** Skip the current step (marks as seen but doesn't complete) */
|
||||
skip: () => void;
|
||||
/** Mark current step as seen and move to next */
|
||||
complete: () => void;
|
||||
/** Update runtime state (e.g., after role selection) */
|
||||
updateRuntimeState: (updates: Partial<OnboardingRuntimeState>) => void;
|
||||
/** Force re-evaluation of the flow (used when conditions change) */
|
||||
refreshFlow: () => void;
|
||||
/** Manually start a specific step (for external triggers) */
|
||||
startStep: (stepId: OnboardingStepId) => void;
|
||||
/** Close/pause onboarding (can be resumed later) */
|
||||
pause: () => void;
|
||||
/** Resume onboarding from where it was paused */
|
||||
resume: () => void;
|
||||
}
|
||||
|
||||
export interface UseOnboardingOrchestratorResult {
|
||||
state: OnboardingOrchestratorState;
|
||||
actions: OnboardingOrchestratorActions;
|
||||
}
|
||||
|
||||
export interface UseOnboardingOrchestratorOptions {
|
||||
/** Override the default runtime state (used by desktop to set isDesktopApp: true) */
|
||||
defaultRuntimeState?: OnboardingRuntimeState;
|
||||
}
|
||||
|
||||
export function useOnboardingOrchestrator(
|
||||
options?: UseOnboardingOrchestratorOptions
|
||||
): UseOnboardingOrchestratorResult {
|
||||
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
|
||||
const serverExperience = useServerExperience();
|
||||
const { config, loading: configLoading } = useAppConfig();
|
||||
const location = useLocation();
|
||||
|
||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
||||
getInitialRuntimeState(defaultState)
|
||||
);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
||||
const migrationDone = useRef(false);
|
||||
const initialIndexSet = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!migrationDone.current) {
|
||||
migrateFromLegacyPreferences();
|
||||
migrationDone.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setRuntimeState((prev) => ({
|
||||
...prev,
|
||||
analyticsEnabled: config?.enableAnalytics === true,
|
||||
analyticsNotConfigured: config?.enableAnalytics == null,
|
||||
licenseNotice: {
|
||||
totalUsers: serverExperience.totalUsers,
|
||||
freeTierLimit: serverExperience.freeTierLimit,
|
||||
isOverLimit: serverExperience.overFreeTierLimit ?? false,
|
||||
requiresLicense: !serverExperience.hasPaidLicense && (
|
||||
serverExperience.overFreeTierLimit === true ||
|
||||
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)
|
||||
),
|
||||
},
|
||||
}));
|
||||
}, [
|
||||
config?.enableAnalytics,
|
||||
serverExperience.totalUsers,
|
||||
serverExperience.freeTierLimit,
|
||||
serverExperience.overFreeTierLimit,
|
||||
serverExperience.hasPaidLicense,
|
||||
serverExperience.effectiveIsAdmin,
|
||||
serverExperience.userCountResolved,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkFirstLogin = async () => {
|
||||
if (config?.enableLogin !== true || !hasAuthToken()) return;
|
||||
|
||||
try {
|
||||
const [accountData, loginPageData] = await Promise.all([
|
||||
accountService.getAccountData(),
|
||||
accountService.getLoginPageData(),
|
||||
]);
|
||||
|
||||
setRuntimeState((prev) => ({
|
||||
...prev,
|
||||
requiresPasswordChange: accountData.changeCredsFlag,
|
||||
firstLoginUsername: accountData.username,
|
||||
usingDefaultCredentials: loginPageData.showDefaultCredentials,
|
||||
}));
|
||||
} catch {
|
||||
// Account endpoint failed - user not logged in or security disabled
|
||||
}
|
||||
};
|
||||
|
||||
if (!configLoading) {
|
||||
checkFirstLogin();
|
||||
}
|
||||
}, [config?.enableLogin, configLoading]);
|
||||
|
||||
const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route));
|
||||
const loginEnabled = config?.enableLogin === true;
|
||||
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
|
||||
const shouldBlockOnboarding = isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
|
||||
|
||||
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
|
||||
...serverExperience,
|
||||
...runtimeState,
|
||||
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
|
||||
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
|
||||
}), [serverExperience, runtimeState]);
|
||||
|
||||
const activeFlow = useMemo(() => {
|
||||
return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext));
|
||||
}, [conditionContext]);
|
||||
|
||||
// Wait for config AND admin status before calculating initial step
|
||||
const adminStatusResolved = !configLoading && (
|
||||
config?.enableLogin === false ||
|
||||
config?.enableLogin === undefined ||
|
||||
config?.isAdmin !== undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (configLoading || !adminStatusResolved || activeFlow.length === 0) return;
|
||||
|
||||
let firstUnseenIndex = -1;
|
||||
for (let i = 0; i < activeFlow.length; i++) {
|
||||
if (!hasSeenStep(activeFlow[i].id)) {
|
||||
firstUnseenIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstUnseenIndex === -1) {
|
||||
setCurrentStepIndex(activeFlow.length);
|
||||
initialIndexSet.current = true;
|
||||
} else if (!initialIndexSet.current) {
|
||||
setCurrentStepIndex(firstUnseenIndex);
|
||||
initialIndexSet.current = true;
|
||||
}
|
||||
}, [activeFlow, configLoading, adminStatusResolved]);
|
||||
|
||||
const totalSteps = activeFlow.length;
|
||||
|
||||
const allStepsAlreadySeen = useMemo(() => {
|
||||
if (activeFlow.length === 0) return false;
|
||||
return activeFlow.every(step => hasSeenStep(step.id));
|
||||
}, [activeFlow]);
|
||||
|
||||
const isComplete = isInitialized && initialIndexSet.current &&
|
||||
(currentStepIndex >= totalSteps || allStepsAlreadySeen);
|
||||
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps && !allStepsAlreadySeen)
|
||||
? activeFlow[currentStepIndex]
|
||||
: null;
|
||||
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
|
||||
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
|
||||
!initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!configLoading && !isInitialized) setIsInitialized(true);
|
||||
}, [configLoading, isInitialized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isComplete) clearRuntimeStateSession();
|
||||
}, [isComplete]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
if (currentStep) markStepSeen(currentStep.id);
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||
}, [currentStep, totalSteps]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
const skip = useCallback(() => {
|
||||
if (currentStep) markStepSeen(currentStep.id);
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||
}, [currentStep, totalSteps]);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
if (currentStep) markStepSeen(currentStep.id);
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||
}, [currentStep, totalSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentStep || isLoading) {
|
||||
return;
|
||||
}
|
||||
if (hasSeenStep(currentStep.id)) {
|
||||
complete();
|
||||
}
|
||||
}, [currentStep, isLoading, complete]);
|
||||
|
||||
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
|
||||
persistRuntimeState(updates);
|
||||
setRuntimeState((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
const refreshFlow = useCallback(() => {
|
||||
initialIndexSet.current = false;
|
||||
setCurrentStepIndex(-1);
|
||||
}, []);
|
||||
|
||||
const startStep = useCallback((stepId: OnboardingStepId) => {
|
||||
const index = activeFlow.findIndex((step) => step.id === stepId);
|
||||
if (index !== -1) {
|
||||
setCurrentStepIndex(index);
|
||||
setIsPaused(false);
|
||||
}
|
||||
}, [activeFlow]);
|
||||
|
||||
const pause = useCallback(() => setIsPaused(true), []);
|
||||
const resume = useCallback(() => setIsPaused(false), []);
|
||||
|
||||
const state: OnboardingOrchestratorState = {
|
||||
isActive,
|
||||
currentStep,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
runtimeState,
|
||||
activeFlow,
|
||||
isComplete,
|
||||
isLoading,
|
||||
};
|
||||
|
||||
const actions: OnboardingOrchestratorActions = {
|
||||
next,
|
||||
prev,
|
||||
skip,
|
||||
complete,
|
||||
updateRuntimeState,
|
||||
refreshFlow,
|
||||
startStep,
|
||||
pause,
|
||||
resume,
|
||||
};
|
||||
|
||||
return { state, actions };
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, PasswordInput, Button, Alert, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import { accountService } from '@app/services/accountService';
|
||||
import { alert as showToast } from '@app/components/toast';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
interface FirstLoginSlideProps {
|
||||
username: string;
|
||||
onPasswordChanged: () => void;
|
||||
usingDefaultCredentials?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PASSWORD = 'stirling';
|
||||
|
||||
function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) {
|
||||
const { t } = useTranslation();
|
||||
// If using default credentials, pre-fill with "stirling" - user won't see this field
|
||||
const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : '');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validation
|
||||
if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) {
|
||||
setError(t('firstLogin.allFieldsRequired', 'All fields are required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t('firstLogin.passwordsDoNotMatch', 'New passwords do not match'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError(t('firstLogin.passwordTooShort', 'Password must be at least 8 characters'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword === currentPassword) {
|
||||
setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
await accountService.changePasswordOnLogin(currentPassword, newPassword);
|
||||
|
||||
showToast({
|
||||
alertType: 'success',
|
||||
title: t('firstLogin.passwordChangedSuccess', 'Password changed successfully! Please log in again.')
|
||||
});
|
||||
|
||||
// Clear form
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
|
||||
// Wait a moment for the user to see the success message
|
||||
setTimeout(() => {
|
||||
onPasswordChanged();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
console.error('Failed to change password:', err);
|
||||
// Extract error message from axios response if available
|
||||
const axiosError = err as { response?: { data?: { message?: string } } };
|
||||
setError(
|
||||
axiosError.response?.data?.message ||
|
||||
t('firstLogin.passwordChangeFailed', 'Failed to change password. Please check your current password.')
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.securitySlideContent}>
|
||||
<div className={styles.securityCard}>
|
||||
<Stack gap="md">
|
||||
<div className={styles.securityAlertRow}>
|
||||
<LocalIcon icon="info-rounded" width={20} height={20} style={{ color: '#3B82F6', flexShrink: 0 }} />
|
||||
<span>
|
||||
{t(
|
||||
'firstLogin.welcomeMessage',
|
||||
'For security reasons, you must change your password on your first login.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
{t('firstLogin.loggedInAs', 'Logged in as')}: <strong>{username}</strong>
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Only show current password field if not using default credentials */}
|
||||
{!usingDefaultCredentials && (
|
||||
<PasswordInput
|
||||
label={t('firstLogin.currentPassword', 'Current Password')}
|
||||
placeholder={t('firstLogin.enterCurrentPassword', 'Enter your current password')}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
|
||||
required
|
||||
styles={{
|
||||
input: { height: 44 },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PasswordInput
|
||||
label={t('firstLogin.newPassword', 'New Password')}
|
||||
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
||||
required
|
||||
styles={{
|
||||
input: { height: 44 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t('firstLogin.confirmPassword', 'Confirm New Password')}
|
||||
placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||
required
|
||||
styles={{
|
||||
input: { height: 44 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={!newPassword || !confirmPassword}
|
||||
size="md"
|
||||
mt="xs"
|
||||
>
|
||||
{t('firstLogin.changePassword', 'Change Password')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FirstLoginSlide({
|
||||
username,
|
||||
onPasswordChanged,
|
||||
usingDefaultCredentials = false,
|
||||
}: FirstLoginSlideProps): SlideConfig {
|
||||
return {
|
||||
key: 'first-login',
|
||||
title: 'Set Your Password',
|
||||
body: (
|
||||
<FirstLoginForm
|
||||
username={username}
|
||||
onPasswordChanged={onPasswordChanged}
|
||||
usingDefaultCredentials={usingDefaultCredentials}
|
||||
/>
|
||||
),
|
||||
background: {
|
||||
gradientStops: ['#059669', '#0891B2'], // Green to teal - security/trust colors
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
export enum TourStep {
|
||||
ALL_TOOLS,
|
||||
SELECT_CROP_TOOL,
|
||||
TOOL_INTERFACE,
|
||||
FILES_BUTTON,
|
||||
FILE_SOURCES,
|
||||
WORKBENCH,
|
||||
VIEW_SWITCHER,
|
||||
VIEWER,
|
||||
PAGE_EDITOR,
|
||||
ACTIVE_FILES,
|
||||
FILE_CHECKBOX,
|
||||
SELECT_CONTROLS,
|
||||
CROP_SETTINGS,
|
||||
RUN_BUTTON,
|
||||
RESULTS,
|
||||
FILE_REPLACEMENT,
|
||||
PIN_BUTTON,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
export enum AdminTourStep {
|
||||
WELCOME,
|
||||
CONFIG_BUTTON,
|
||||
SETTINGS_OVERVIEW,
|
||||
TEAMS_AND_USERS,
|
||||
SYSTEM_CUSTOMIZATION,
|
||||
DATABASE_SECTION,
|
||||
CONNECTIONS_SECTION,
|
||||
ADMIN_TOOLS,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* useOnboardingDownload Hook
|
||||
*
|
||||
* Encapsulates OS detection and download URL logic for the desktop install slide.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useOs } from '@app/hooks/useOs';
|
||||
import { DOWNLOAD_URLS } from '@app/constants/downloads';
|
||||
|
||||
interface OsInfo {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface OsOption {
|
||||
label: string;
|
||||
url: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface UseOnboardingDownloadResult {
|
||||
osInfo: OsInfo;
|
||||
osOptions: OsOption[];
|
||||
selectedDownloadUrl: string;
|
||||
setSelectedDownloadUrl: (url: string) => void;
|
||||
handleDownloadSelected: () => void;
|
||||
}
|
||||
|
||||
export function useOnboardingDownload(): UseOnboardingDownloadResult {
|
||||
const osType = useOs();
|
||||
const [selectedDownloadUrl, setSelectedDownloadUrl] = useState<string>('');
|
||||
|
||||
const osInfo = useMemo<OsInfo>(() => {
|
||||
switch (osType) {
|
||||
case 'windows':
|
||||
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
|
||||
case 'mac-apple':
|
||||
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
||||
case 'mac-intel':
|
||||
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
|
||||
case 'linux-x64':
|
||||
case 'linux-arm64':
|
||||
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
|
||||
default:
|
||||
return { label: '', url: '' };
|
||||
}
|
||||
}, [osType]);
|
||||
|
||||
const osOptions = useMemo<OsOption[]>(() => [
|
||||
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
|
||||
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
|
||||
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
|
||||
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
|
||||
].filter((opt) => opt.url), []);
|
||||
|
||||
// Initialize selected URL from detected OS
|
||||
useEffect(() => {
|
||||
if (!selectedDownloadUrl && osInfo.url) {
|
||||
setSelectedDownloadUrl(osInfo.url);
|
||||
}
|
||||
}, [osInfo.url, selectedDownloadUrl]);
|
||||
|
||||
const handleDownloadSelected = useCallback(() => {
|
||||
const downloadUrl = selectedDownloadUrl || osInfo.url;
|
||||
if (downloadUrl) {
|
||||
window.open(downloadUrl, '_blank', 'noopener');
|
||||
}
|
||||
}, [selectedDownloadUrl, osInfo.url]);
|
||||
|
||||
return {
|
||||
osInfo,
|
||||
osOptions,
|
||||
selectedDownloadUrl,
|
||||
setSelectedDownloadUrl,
|
||||
handleDownloadSelected,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useCallback, useState } from 'react';
|
||||
import {
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
START_TOUR_EVENT,
|
||||
type ServerLicenseRequestPayload,
|
||||
type TourType,
|
||||
type StartTourPayload,
|
||||
} from '@app/constants/events';
|
||||
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
|
||||
export function useServerLicenseRequest(): {
|
||||
showLicenseSlide: boolean;
|
||||
licenseNotice: OnboardingRuntimeState['licenseNotice'] | null;
|
||||
closeLicenseSlide: () => void;
|
||||
} {
|
||||
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
|
||||
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState['licenseNotice'] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleLicenseRequest = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
|
||||
|
||||
if (detail?.licenseNotice) {
|
||||
setLicenseNotice({
|
||||
totalUsers: detail.licenseNotice.totalUsers ?? null,
|
||||
freeTierLimit: detail.licenseNotice.freeTierLimit ?? 5,
|
||||
isOverLimit: detail.licenseNotice.isOverLimit ?? false,
|
||||
requiresLicense: true,
|
||||
});
|
||||
}
|
||||
|
||||
setShowLicenseSlide(true);
|
||||
};
|
||||
|
||||
window.addEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
|
||||
return () => window.removeEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
|
||||
}, []);
|
||||
|
||||
const closeLicenseSlide = useCallback(() => {
|
||||
setShowLicenseSlide(false);
|
||||
}, []);
|
||||
|
||||
return { showLicenseSlide, licenseNotice, closeLicenseSlide };
|
||||
}
|
||||
|
||||
export function useTourRequest(): {
|
||||
tourRequested: boolean;
|
||||
requestedTourType: TourType;
|
||||
clearTourRequest: () => void;
|
||||
} {
|
||||
const [tourRequested, setTourRequested] = useState(false);
|
||||
const [requestedTourType, setRequestedTourType] = useState<TourType>('tools');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleTourRequest = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<StartTourPayload>;
|
||||
setRequestedTourType(detail?.tourType ?? 'tools');
|
||||
setTourRequested(true);
|
||||
};
|
||||
|
||||
window.addEventListener(START_TOUR_EVENT, handleTourRequest);
|
||||
return () => window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
|
||||
}, []);
|
||||
|
||||
const clearTourRequest = useCallback(() => {
|
||||
setTourRequested(false);
|
||||
}, []);
|
||||
|
||||
return { tourRequested, requestedTourType, clearTourRequest };
|
||||
}
|
||||
@@ -1,6 +1,26 @@
|
||||
import type { StepType } from '@reactour/tour';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { TourStep } from '@app/components/onboarding/tourSteps';
|
||||
|
||||
export enum TourStep {
|
||||
ALL_TOOLS,
|
||||
SELECT_CROP_TOOL,
|
||||
TOOL_INTERFACE,
|
||||
FILES_BUTTON,
|
||||
FILE_SOURCES,
|
||||
WORKBENCH,
|
||||
VIEW_SWITCHER,
|
||||
VIEWER,
|
||||
PAGE_EDITOR,
|
||||
ACTIVE_FILES,
|
||||
FILE_CHECKBOX,
|
||||
SELECT_CONTROLS,
|
||||
CROP_SETTINGS,
|
||||
RUN_BUTTON,
|
||||
RESULTS,
|
||||
FILE_REPLACEMENT,
|
||||
PIN_BUTTON,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
interface UserStepActions {
|
||||
saveWorkbenchState: () => void;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import '@app/components/shared/AppConfigModal.css';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { Z_INDEX_CONFIG_MODAL, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
|
||||
import { UnsavedChangesProvider, useUnsavedChanges } from '@app/contexts/UnsavedChangesContext';
|
||||
|
||||
@@ -122,7 +122,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
zIndex={Z_INDEX_CONFIG_MODAL}
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
|
||||
@@ -130,11 +130,6 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
onClick={onButtonClick}
|
||||
loading={loading}
|
||||
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
|
||||
styles={{
|
||||
label: {
|
||||
color: textColor ?? toneStyle.text,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
|
||||
@@ -15,8 +15,8 @@ import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
|
||||
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
|
||||
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||
import { requestStartTour } from '@app/constants/events';
|
||||
|
||||
import {
|
||||
isNavButtonActive,
|
||||
@@ -34,7 +34,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
const { config } = useAppConfig();
|
||||
const { startTour } = useOnboarding();
|
||||
const licenseAlert = useLicenseAlert();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
@@ -269,7 +268,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
<div
|
||||
key={buttonConfig.id}
|
||||
data-tour="help-button"
|
||||
onClick={() => startTour('tools')}
|
||||
onClick={() => requestStartTour('tools')}
|
||||
>
|
||||
{renderNavButton(buttonConfig, index)}
|
||||
</div>
|
||||
@@ -286,7 +285,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
|
||||
onClick={() => startTour('tools')}
|
||||
onClick={() => requestStartTour('tools')}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
@@ -299,7 +298,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />}
|
||||
onClick={() => startTour('admin')}
|
||||
onClick={() => requestStartTour('admin')}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
|
||||
@@ -3,65 +3,39 @@ import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import '@app/components/tools/ToolPanelModePrompt.css';
|
||||
import { type ToolPanelMode } from '@app/constants/toolPanel';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
interface ToolPanelModePromptProps {
|
||||
onComplete?: () => void;
|
||||
/** If true, the modal will be forced open (used by orchestrator) */
|
||||
forceOpen?: boolean;
|
||||
}
|
||||
|
||||
const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => {
|
||||
/**
|
||||
* ToolPanelModePrompt - Lets users choose between sidebar and fullscreen tool modes
|
||||
*
|
||||
* The orchestrator controls this via forceOpen prop. When shown standalone (legacy),
|
||||
* it uses internal state based on preferences.
|
||||
*/
|
||||
const ToolPanelModePrompt = ({ onComplete, forceOpen }: ToolPanelModePromptProps = {}) => {
|
||||
const { t } = useTranslation();
|
||||
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const {
|
||||
startTour,
|
||||
startAfterToolModeSelection,
|
||||
setStartAfterToolModeSelection,
|
||||
pendingTourRequest,
|
||||
} = useOnboarding();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const { config } = useAppConfig();
|
||||
const isAdmin = !!config?.isAdmin;
|
||||
const [internalOpened, setInternalOpened] = useState(false);
|
||||
|
||||
// Only show after the new 3-slide onboarding has been completed
|
||||
// Only show after the intro onboarding has been completed (legacy standalone mode)
|
||||
const shouldShowPrompt = !preferences.toolPanelModePromptSeen && preferences.hasSeenIntroOnboarding;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowPrompt) {
|
||||
setOpened(true);
|
||||
if (shouldShowPrompt && forceOpen === undefined) {
|
||||
setInternalOpened(true);
|
||||
}
|
||||
}, [shouldShowPrompt]);
|
||||
}, [shouldShowPrompt, forceOpen]);
|
||||
|
||||
const resolveRequestedTourType = (): 'admin' | 'tools' => {
|
||||
if (pendingTourRequest?.type) {
|
||||
return pendingTourRequest.type;
|
||||
}
|
||||
if (pendingTourRequest?.metadata && 'selfReportedAdmin' in pendingTourRequest.metadata) {
|
||||
return pendingTourRequest.metadata.selfReportedAdmin ? 'admin' : 'tools';
|
||||
}
|
||||
return isAdmin ? 'admin' : 'tools';
|
||||
};
|
||||
|
||||
const resumeDeferredTour = (context?: { selection?: ToolPanelMode; dismissed?: boolean }) => {
|
||||
if (!startAfterToolModeSelection) {
|
||||
return;
|
||||
}
|
||||
setStartAfterToolModeSelection(false);
|
||||
const targetType = resolveRequestedTourType();
|
||||
startTour(targetType, {
|
||||
skipToolPromptRequirement: true,
|
||||
source: 'tool-panel-mode-prompt',
|
||||
metadata: {
|
||||
...pendingTourRequest?.metadata,
|
||||
resumedFromToolPrompt: true,
|
||||
...(context?.selection ? { selection: context.selection } : {}),
|
||||
...(context?.dismissed ? { dismissed: true } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
// If forceOpen is provided, use it; otherwise use internal state
|
||||
const opened = forceOpen ?? internalOpened;
|
||||
const setOpened = forceOpen !== undefined ? () => {} : setInternalOpened;
|
||||
|
||||
const handleSelect = (mode: ToolPanelMode) => {
|
||||
setToolPanelMode(mode);
|
||||
@@ -69,8 +43,6 @@ const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => {
|
||||
updatePreference('toolPanelModePromptSeen', true);
|
||||
updatePreference('hasSelectedToolPanelMode', true);
|
||||
setOpened(false);
|
||||
|
||||
resumeDeferredTour({ selection: mode });
|
||||
onComplete?.();
|
||||
};
|
||||
|
||||
@@ -83,7 +55,6 @@ const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => {
|
||||
updatePreference('hasSelectedToolPanelMode', true);
|
||||
updatePreference('toolPanelModePromptSeen', true);
|
||||
setOpened(false);
|
||||
resumeDeferredTour({ dismissed: true });
|
||||
onComplete?.();
|
||||
};
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ const EmbedPdfViewerContent = ({
|
||||
isThumbnailSidebarVisible,
|
||||
toggleThumbnailSidebar,
|
||||
isBookmarkSidebarVisible,
|
||||
searchInterfaceActions,
|
||||
zoomActions,
|
||||
panActions: _panActions,
|
||||
rotationActions: _rotationActions,
|
||||
@@ -184,7 +185,7 @@ const EmbedPdfViewerContent = ({
|
||||
onZoomOut: zoomActions.zoomOut,
|
||||
});
|
||||
|
||||
// Handle keyboard zoom shortcuts
|
||||
// Handle keyboard shortcuts (zoom and search)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isViewerHovered) return;
|
||||
@@ -199,6 +200,10 @@ const EmbedPdfViewerContent = ({
|
||||
// Ctrl+- for zoom out
|
||||
event.preventDefault();
|
||||
zoomActions.zoomOut();
|
||||
} else if (event.key === 'f' || event.key === 'F') {
|
||||
// Ctrl+F for search
|
||||
event.preventDefault();
|
||||
searchInterfaceActions.open();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -207,7 +212,7 @@ const EmbedPdfViewerContent = ({
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isViewerHovered]);
|
||||
}, [isViewerHovered, zoomActions, searchInterfaceActions]);
|
||||
|
||||
// Register checker for unsaved changes (annotations only for now)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
||||
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
|
||||
import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark';
|
||||
import { PrintPluginPackage } from '@embedpdf/plugin-print/react';
|
||||
|
||||
// Import annotation plugins
|
||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||
@@ -41,6 +42,7 @@ import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
|
||||
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
|
||||
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
|
||||
import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
|
||||
import { isPdfFile } from '@app/utils/fileUtils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LinkLayer } from '@app/components/viewer/LinkLayer';
|
||||
@@ -95,7 +97,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
strategy: ScrollStrategy.Vertical,
|
||||
initialPage: 0,
|
||||
}),
|
||||
createPluginRegistration(RenderPluginPackage),
|
||||
createPluginRegistration(RenderPluginPackage, {
|
||||
withForms: true,
|
||||
withAnnotations: true,
|
||||
}),
|
||||
|
||||
// Register interaction manager (required for zoom and selection features)
|
||||
createPluginRegistration(InteractionManagerPluginPackage),
|
||||
@@ -156,6 +161,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
createPluginRegistration(ExportPluginPackage, {
|
||||
defaultFileName: 'document.pdf',
|
||||
}),
|
||||
|
||||
// Register print plugin for printing PDFs
|
||||
createPluginRegistration(PrintPluginPackage),
|
||||
];
|
||||
}, [pdfUrl]);
|
||||
|
||||
@@ -301,6 +309,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
|
||||
<ExportAPIBridge />
|
||||
<BookmarkAPIBridge />
|
||||
<PrintAPIBridge />
|
||||
<GlobalPointerProvider>
|
||||
<Viewport
|
||||
style={{
|
||||
|
||||
@@ -85,7 +85,10 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
strategy: ScrollStrategy.Vertical,
|
||||
initialPage: 0,
|
||||
}),
|
||||
createPluginRegistration(RenderPluginPackage),
|
||||
createPluginRegistration(RenderPluginPackage, {
|
||||
withForms: true,
|
||||
withAnnotations: true,
|
||||
}),
|
||||
|
||||
// Register interaction manager (required for annotations)
|
||||
createPluginRegistration(InteractionManagerPluginPackage),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from 'react';
|
||||
import { usePrintCapability } from '@embedpdf/plugin-print/react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
/**
|
||||
* Component that runs inside EmbedPDF context and exposes print API to ViewerContext
|
||||
*/
|
||||
export function PrintAPIBridge() {
|
||||
const { provides: print } = usePrintCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
|
||||
useEffect(() => {
|
||||
if (print) {
|
||||
// Register this bridge with ViewerContext
|
||||
registerBridge('print', {
|
||||
state: {},
|
||||
api: {
|
||||
print: () => print.print(),
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [print, registerBridge]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Box, TextInput, ActionIcon, Text, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
@@ -12,7 +12,9 @@ interface SearchInterfaceProps {
|
||||
export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
const { t } = useTranslation();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const searchState = viewerContext?.getSearchState();
|
||||
const searchResults = searchState?.results;
|
||||
const searchActiveIndex = searchState?.activeIndex;
|
||||
@@ -26,6 +28,38 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
} | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Auto-focus search input when visible
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Auto-search as user types (debounced)
|
||||
useEffect(() => {
|
||||
// Clear existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
// If query is empty, clear search immediately
|
||||
if (!searchQuery.trim()) {
|
||||
handleClearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search by 300ms
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
handleSearch(searchQuery);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
// Monitor search state changes
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
@@ -123,7 +157,14 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const _handleClose = () => {
|
||||
const handleInputBlur = () => {
|
||||
// Close popover on blur if no text is entered
|
||||
if (!searchQuery.trim()) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseClick = () => {
|
||||
handleClearSearch();
|
||||
onClose();
|
||||
};
|
||||
@@ -135,37 +176,44 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
padding: '0px'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
{/* Header with close button */}
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('search.title', 'Search PDF')}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleCloseClick}
|
||||
aria-label="Close search"
|
||||
>
|
||||
<LocalIcon icon="close" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* Search input */}
|
||||
<Group mb="md">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
placeholder={t('search.placeholder', 'Enter search term...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
const newValue = e.currentTarget.value;
|
||||
setSearchQuery(newValue);
|
||||
// If user clears the input, clear the search highlights
|
||||
if (!newValue.trim()) {
|
||||
handleClearSearch();
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
style={{ flex: 1 }}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => handleSearch(searchQuery)}
|
||||
disabled={!searchQuery.trim() || isSearching}
|
||||
loading={isSearching}
|
||||
>
|
||||
<LocalIcon icon="search" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
searchQuery.trim() && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={handleClearSearch}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<LocalIcon icon="close" width="0.875rem" height="0.875rem" />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -24,6 +24,7 @@ export function useViewerRightRailButtons() {
|
||||
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
|
||||
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
||||
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
|
||||
const printLabel = t('rightRail.print', 'Print PDF');
|
||||
|
||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
return [
|
||||
@@ -35,7 +36,14 @@ export function useViewerRightRailButtons() {
|
||||
order: 10,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={searchLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
|
||||
<Popover position={tooltipPosition} withArrow shadow="md" offset={8}>
|
||||
<Popover
|
||||
position={tooltipPosition}
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
opened={viewer.isSearchInterfaceVisible}
|
||||
onClose={viewer.searchInterfaceActions.close}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
@@ -44,6 +52,7 @@ export function useViewerRightRailButtons() {
|
||||
className="right-rail-icon"
|
||||
disabled={disabled}
|
||||
aria-label={searchLabel}
|
||||
onClick={viewer.searchInterfaceActions.toggle}
|
||||
>
|
||||
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
@@ -51,7 +60,7 @@ export function useViewerRightRailButtons() {
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '20rem' }}>
|
||||
<SearchInterface visible={true} onClose={() => {}} />
|
||||
<SearchInterface visible={viewer.isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
@@ -127,6 +136,17 @@ export function useViewerRightRailButtons() {
|
||||
viewer.toggleBookmarkSidebar();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-print',
|
||||
icon: <LocalIcon icon="print" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: printLabel,
|
||||
ariaLabel: printLabel,
|
||||
section: 'top' as const,
|
||||
order: 56,
|
||||
onClick: () => {
|
||||
viewer.printActions.print();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-annotation-controls',
|
||||
section: 'top' as const,
|
||||
@@ -136,7 +156,7 @@ export function useViewerRightRailButtons() {
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, tooltipPosition]);
|
||||
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, printLabel, tooltipPosition]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
|
||||
// Base path from Vite config - build-time constant, normalized (no trailing slash)
|
||||
// When no subpath, use empty string instead of '.' to avoid relative path issues
|
||||
export const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '').replace(/^\.$/, '');
|
||||
// Base path - read from <base> tag at runtime to support dynamic subpaths
|
||||
// Falls back to Vite's BASE_URL for build-time subpaths
|
||||
// Normalized: no trailing slash, empty string instead of '.' or '/'
|
||||
function getBasePath(): string {
|
||||
// Try to read from <base> tag first (runtime subpath support)
|
||||
if (typeof document !== 'undefined') {
|
||||
const baseElement = document.querySelector('base');
|
||||
if (baseElement) {
|
||||
const href = baseElement.getAttribute('href');
|
||||
if (href && href !== '%BASE_URL%') {
|
||||
return href.replace(/\/$/, '').replace(/^\.$/, '').replace(/^\/$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to Vite's BASE_URL (build-time subpath)
|
||||
return (import.meta.env.BASE_URL || '/').replace(/\/$/, '').replace(/^\.$/, '').replace(/^\/$/, '');
|
||||
}
|
||||
|
||||
export const BASE_PATH = getBasePath();
|
||||
|
||||
// EmbedPDF needs time to remove annotations internally before a recreation runs.
|
||||
// Without the buffer we occasionally end up with duplicate annotations or stale image data.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
|
||||
export const ONBOARDING_SESSION_BLOCK_KEY = 'stirling-onboarding-session-active';
|
||||
export const ONBOARDING_SESSION_EVENT = 'stirling:onboarding-session-started';
|
||||
export const SERVER_LICENSE_REQUEST_EVENT = 'stirling:server-license-requested';
|
||||
export const UPGRADE_BANNER_TEST_EVENT = 'stirling:upgrade-banner-test';
|
||||
export const UPGRADE_BANNER_ALERT_EVENT = 'stirling:upgrade-banner-alert';
|
||||
export const START_TOUR_EVENT = 'stirling:start-tour';
|
||||
export const TOUR_STATE_EVENT = 'stirling:tour-state';
|
||||
|
||||
export interface ServerLicenseRequestPayload {
|
||||
licenseNotice?: Partial<LicenseNotice>;
|
||||
@@ -25,3 +25,29 @@ export interface UpgradeBannerAlertPayload {
|
||||
freeTierLimit?: number;
|
||||
}
|
||||
|
||||
export type TourType = 'admin' | 'tools';
|
||||
|
||||
export interface StartTourPayload {
|
||||
tourType: TourType;
|
||||
}
|
||||
|
||||
export interface TourStatePayload {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
/** Helper to dispatch the start tour event */
|
||||
export function requestStartTour(tourType: TourType): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<StartTourPayload>(START_TOUR_EVENT, { detail: { tourType } })
|
||||
);
|
||||
}
|
||||
|
||||
/** Helper to dispatch tour state changes (for hiding cookie consent during tour) */
|
||||
export function dispatchTourState(isOpen: boolean): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<TourStatePayload>(TOUR_STATE_EVENT, { detail: { isOpen } })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user