Add frontend autoformatting and set CI to require formatted code for all languages (#6052)

# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
This commit is contained in:
James Brunton
2026-04-10 17:41:19 +01:00
committed by GitHub
parent 33b2b5827a
commit a3e45bc182
1359 changed files with 57784 additions and 57460 deletions
+104
View File
@@ -50,6 +50,7 @@ jobs:
permissions:
actions: read
security-events: write
pull-requests: write
strategy:
fail-fast: false
matrix:
@@ -84,6 +85,60 @@ jobs:
gradle-version: 9.3.1
cache-disabled: true
- name: Check Java formatting (Spotless)
if: matrix.jdk-version == 25 && matrix.spring-security == false
id: spotless-check
run: ./gradlew spotlessCheck
continue-on-error: true
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Comment on Java formatting failure
if: steps.spotless-check.outcome == 'failure'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const marker = '<!-- java-formatting-check -->';
const body = [
marker,
'### Java Formatting Check Failed',
'',
'Your code has formatting issues. Run the following command to fix them:',
'',
'```bash',
'./gradlew spotlessApply',
'```',
'',
'Then commit and push the changes.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if Java formatting issues found
if: steps.spotless-check.outcome == 'failure'
run: exit 1
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew build -PnoSpotless
env:
@@ -187,6 +242,9 @@ jobs:
if: needs.files-changed.outputs.frontend == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
@@ -202,6 +260,52 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Check TypeScript formatting (Prettier)
id: prettier-check
run: cd frontend && npm run format:check
continue-on-error: true
- name: Comment on TypeScript formatting failure
if: steps.prettier-check.outcome == 'failure'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const marker = '<!-- typescript-formatting-check -->';
const body = [
marker,
'### TypeScript Formatting Check Failed',
'',
'Your code has formatting issues. Run the following command to fix them:',
'',
'```bash',
'cd frontend && npm run fix',
'```',
'',
'Then commit and push the changes.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if TypeScript formatting issues found
if: steps.prettier-check.outcome == 'failure'
run: exit 1
- name: Type-check frontend
run: cd frontend && npm run prep && npm run typecheck:all
- name: Lint frontend
+2 -55
View File
@@ -2,7 +2,7 @@ name: Pre-commit
on:
workflow_dispatch:
push:
pull_request:
branches:
- main
@@ -16,9 +16,6 @@ jobs:
# Prevents sdist builds → no tar extraction
PIP_ONLY_BINARY: ":all:"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
@@ -31,13 +28,6 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@@ -57,47 +47,4 @@ jobs:
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
continue-on-error: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 9.3.1
- name: Build with Gradle
run: ./gradlew build
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: git add
run: |
git add .
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Create Pull Request
if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: ":file_folder: pre-commit"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: pre-commit
title: "🤖 format everything with pre-commit by ${{ steps.setup-bot.outputs.app-slug }}"
body: |
Auto-generated by [create-pull-request][1] with **${{ steps.setup-bot.outputs.app-slug }}**
[1]: https://github.com/peter-evans/create-pull-request
draft: false
delete-branch: true
labels: github-actions
sign-commits: true
git diff --exit-code
+2 -2
View File
@@ -110,11 +110,11 @@ tasks.register('syncAppVersion') {
[new File(sim1Path), new File(sim2Path)].each { f ->
if (f.exists()) {
def content = f.getText('UTF-8')
def matcher = (content =~ /(appVersion:\s*')([^']*)(')/)
def matcher = (content =~ /(appVersion:\s*(['"]))(.*?)(\2)/)
if (!matcher.find()) {
throw new GradleException("Could not locate appVersion in ${f} for synchronization")
}
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${appVersionStr}${matcher.group(3)}")
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${appVersionStr}${matcher.group(4)}")
if (content != updatedContent) {
f.write(updatedContent, 'UTF-8')
}
+10
View File
@@ -0,0 +1,10 @@
dist/
node_modules/
public/vendor/
public/pdfjs*/
public/js/thirdParty/
public/css/cookieconsent.css
*.min.*
*.md
*.wxs
src/output.css
+38 -48
View File
@@ -1,62 +1,52 @@
// @ts-check
import eslint from '@eslint/js';
import globals from 'globals';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import eslint from "@eslint/js";
import globals from "globals";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
const srcGlobs = [
'src/**/*.{js,mjs,jsx,ts,tsx}',
];
const nodeGlobs = [
'scripts/**/*.{js,ts,mjs}',
'*.config.{js,ts,mjs}',
];
const srcGlobs = ["src/**/*.{js,mjs,jsx,ts,tsx}"];
const nodeGlobs = ["scripts/**/*.{js,ts,mjs}", "*.config.{js,ts,mjs}"];
const baseRestrictedImportPatterns = [
{ regex: '^\\.', message: "Use @app/* imports instead of relative imports." },
{ regex: '^src/', message: "Use @app/* imports instead of absolute src/ imports." },
{ regex: "^\\.", message: "Use @app/* imports instead of relative imports." },
{ regex: "^src/", message: "Use @app/* imports instead of absolute src/ imports." },
];
export default defineConfig(
{
// Everything that contains 3rd party code that we don't want to lint
ignores: [
'dist',
'node_modules',
'public',
'src-tauri',
],
ignores: ["dist", "node_modules", "public", "src-tauri"],
},
eslint.configs.recommended,
tseslint.configs.recommended,
{
rules: {
'no-restricted-imports': [
'error',
"no-restricted-imports": [
"error",
{
patterns: baseRestrictedImportPatterns,
},
],
'@typescript-eslint/no-empty-object-type': [
'error',
"@typescript-eslint/no-empty-object-type": [
"error",
{
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
allowInterfaces: 'with-single-extends',
allowInterfaces: "with-single-extends",
},
],
'@typescript-eslint/no-explicit-any': 'off', // Temporarily disabled until codebase conformant
'@typescript-eslint/no-require-imports': 'off', // Temporarily disabled until codebase conformant
'@typescript-eslint/no-unused-vars': [
'error',
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-unused-vars": [
"error",
{
'args': 'all', // All function args must be used (or explicitly ignored)
'argsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'caughtErrors': 'all', // Caught errors must be used (or explicitly ignored)
'caughtErrorsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'destructuredArrayIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'varsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'ignoreRestSiblings': true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
args: "all", // All function args must be used (or explicitly ignored)
argsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
caughtErrors: "all", // Caught errors must be used (or explicitly ignored)
caughtErrorsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
destructuredArrayIgnorePattern: "^_", // Allow unused variables beginning with an underscore
varsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
ignoreRestSiblings: true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
},
],
},
@@ -65,15 +55,15 @@ export default defineConfig(
// Use the stub/shadow pattern instead: define a stub in src/core/ and override in src/desktop/.
{
files: srcGlobs,
ignores: ['src/desktop/**'],
ignores: ["src/desktop/**"],
rules: {
'no-restricted-imports': [
'error',
"no-restricted-imports": [
"error",
{
patterns: [
...baseRestrictedImportPatterns,
{
regex: '^@tauri-apps/',
regex: "^@tauri-apps/",
message: "Tauri APIs are desktop-only. Review frontend/DeveloperGuide.md for structure advice.",
},
],
@@ -84,9 +74,9 @@ export default defineConfig(
// Folders that have been cleaned up and are now conformant - stricter rules enforced here
{
files: [
'src/proprietary/**/*.{js,mjs,jsx,ts,tsx}',
'src/saas/**/*.{js,mjs,jsx,ts,tsx}',
'src/prototypes/**/*.{js,mjs,jsx,ts,tsx}',
"src/proprietary/**/*.{js,mjs,jsx,ts,tsx}",
"src/saas/**/*.{js,mjs,jsx,ts,tsx}",
"src/prototypes/**/*.{js,mjs,jsx,ts,tsx}",
],
languageOptions: {
parserOptions: {
@@ -95,8 +85,8 @@ export default defineConfig(
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
},
},
// Config for browser scripts
@@ -105,8 +95,8 @@ export default defineConfig(
languageOptions: {
globals: {
...globals.browser,
}
}
},
},
},
// Config for node scripts
{
@@ -114,7 +104,7 @@ export default defineConfig(
languageOptions: {
globals: {
...globals.node,
}
}
},
},
},
);
+2 -5
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="UTF-8" />
@@ -6,10 +6,7 @@
<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)"
/>
<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" />
+17
View File
@@ -114,6 +114,7 @@
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1",
"puppeteer": "^24.25.0",
"tsx": "^4.21.0",
"typescript": "^5.9.2",
@@ -11308,6 +11309,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+5
View File
@@ -89,8 +89,12 @@
"dev:saas": "npm run prep:saas && vite --mode saas",
"dev:desktop": "npm run prep:desktop && vite --mode desktop",
"dev:prototypes": "npm run prep && vite --mode prototypes",
"fix": "npm run format && npm run lint:fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "npm run lint:eslint && npm run lint:cycles",
"lint:eslint": "eslint --max-warnings=0",
"lint:fix": "eslint --fix",
"lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1",
"build": "npm run prep && vite build",
"build:core": "npm run prep && vite build --mode core",
@@ -176,6 +180,7 @@
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1",
"puppeteer": "^24.25.0",
"tsx": "^4.21.0",
"typescript": "^5.9.2",
+17 -17
View File
@@ -1,11 +1,11 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from "@playwright/test";
/**
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './src/core/tests',
testMatch: '**/*.spec.ts',
testDir: "./src/core/tests",
testMatch: "**/*.spec.ts",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -15,34 +15,34 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:5173',
baseURL: "http://localhost:5173",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1080 }
name: "chromium",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1920, height: 1080 },
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
@@ -68,8 +68,8 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
command: "npm run dev",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
},
});
});
+1 -4
View File
@@ -1,6 +1,3 @@
module.exports = {
plugins: [
require('@tailwindcss/postcss'),
require('autoprefixer'),
],
plugins: [require("@tailwindcss/postcss"), require("autoprefixer")],
};
+120 -121
View File
@@ -1,206 +1,205 @@
/* Light theme variables */
:root {
--cc-bg: #ffffff;
--cc-primary-color: #1c1c1c;
--cc-secondary-color: #666666;
--cc-bg: #ffffff;
--cc-primary-color: #1c1c1c;
--cc-secondary-color: #666666;
--cc-btn-primary-bg: #007BFF;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #007BFF;
--cc-btn-primary-hover-bg: #0056b3;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #0056b3;
--cc-btn-primary-bg: #007bff;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #007bff;
--cc-btn-primary-hover-bg: #0056b3;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #0056b3;
--cc-btn-secondary-bg: #f1f3f4;
--cc-btn-secondary-color: #1c1c1c;
--cc-btn-secondary-border-color: #f1f3f4;
--cc-btn-secondary-hover-bg: #007BFF;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #007BFF;
--cc-btn-secondary-bg: #f1f3f4;
--cc-btn-secondary-color: #1c1c1c;
--cc-btn-secondary-border-color: #f1f3f4;
--cc-btn-secondary-hover-bg: #007bff;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #007bff;
--cc-separator-border-color: #e0e0e0;
--cc-separator-border-color: #e0e0e0;
--cc-toggle-on-bg: #007BFF;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #ffffff;
--cc-toggle-off-knob-bg: #ffffff;
--cc-toggle-on-bg: #007bff;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #ffffff;
--cc-toggle-off-knob-bg: #ffffff;
--cc-toggle-enabled-icon-color: #ffffff;
--cc-toggle-disabled-icon-color: #ffffff;
--cc-toggle-enabled-icon-color: #ffffff;
--cc-toggle-disabled-icon-color: #ffffff;
--cc-toggle-readonly-bg: #f1f3f4;
--cc-toggle-readonly-knob-bg: #79747E;
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
--cc-toggle-readonly-bg: #f1f3f4;
--cc-toggle-readonly-knob-bg: #79747e;
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
--cc-section-category-border: #e0e0e0;
--cc-section-category-border: #e0e0e0;
--cc-cookie-category-block-bg: #f1f3f4;
--cc-cookie-category-block-border: #f1f3f4;
--cc-cookie-category-block-hover-bg: #e9eff4;
--cc-cookie-category-block-hover-border: #e9eff4;
--cc-cookie-category-expanded-block-bg: #f1f3f4;
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
--cc-cookie-category-block-bg: #f1f3f4;
--cc-cookie-category-block-border: #f1f3f4;
--cc-cookie-category-block-hover-bg: #e9eff4;
--cc-cookie-category-block-hover-border: #e9eff4;
--cc-footer-bg: #ffffff;
--cc-footer-color: #1c1c1c;
--cc-footer-border-color: #ffffff;
--cc-cookie-category-expanded-block-bg: #f1f3f4;
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
--cc-footer-bg: #ffffff;
--cc-footer-color: #1c1c1c;
--cc-footer-border-color: #ffffff;
}
/* Dark theme variables */
.cc--darkmode{
--cc-bg: #2d2d2d;
--cc-primary-color: #e5e5e5;
--cc-secondary-color: #b0b0b0;
.cc--darkmode {
--cc-bg: #2d2d2d;
--cc-primary-color: #e5e5e5;
--cc-secondary-color: #b0b0b0;
--cc-btn-primary-bg: #4dabf7;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #4dabf7;
--cc-btn-primary-hover-bg: #3d3d3d;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #3d3d3d;
--cc-btn-primary-bg: #4dabf7;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #4dabf7;
--cc-btn-primary-hover-bg: #3d3d3d;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #3d3d3d;
--cc-btn-secondary-bg: #3d3d3d;
--cc-btn-secondary-color: #ffffff;
--cc-btn-secondary-border-color: #3d3d3d;
--cc-btn-secondary-hover-bg: #4dabf7;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #4dabf7;
--cc-btn-secondary-bg: #3d3d3d;
--cc-btn-secondary-color: #ffffff;
--cc-btn-secondary-border-color: #3d3d3d;
--cc-btn-secondary-hover-bg: #4dabf7;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #4dabf7;
--cc-separator-border-color: #555555;
--cc-separator-border-color: #555555;
--cc-toggle-on-bg: #4dabf7;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #2d2d2d;
--cc-toggle-off-knob-bg: #2d2d2d;
--cc-toggle-on-bg: #4dabf7;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #2d2d2d;
--cc-toggle-off-knob-bg: #2d2d2d;
--cc-toggle-enabled-icon-color: #2d2d2d;
--cc-toggle-disabled-icon-color: #2d2d2d;
--cc-toggle-enabled-icon-color: #2d2d2d;
--cc-toggle-disabled-icon-color: #2d2d2d;
--cc-toggle-readonly-bg: #555555;
--cc-toggle-readonly-knob-bg: #8e8e8e;
--cc-toggle-readonly-knob-icon-color: #555555;
--cc-toggle-readonly-bg: #555555;
--cc-toggle-readonly-knob-bg: #8e8e8e;
--cc-toggle-readonly-knob-icon-color: #555555;
--cc-section-category-border: #555555;
--cc-section-category-border: #555555;
--cc-cookie-category-block-bg: #3d3d3d;
--cc-cookie-category-block-border: #3d3d3d;
--cc-cookie-category-block-hover-bg: #4d4d4d;
--cc-cookie-category-block-hover-border: #4d4d4d;
--cc-cookie-category-expanded-block-bg: #3d3d3d;
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
--cc-cookie-category-block-bg: #3d3d3d;
--cc-cookie-category-block-border: #3d3d3d;
--cc-cookie-category-block-hover-bg: #4d4d4d;
--cc-cookie-category-block-hover-border: #4d4d4d;
--cc-footer-bg: #2d2d2d;
--cc-footer-color: #e5e5e5;
--cc-footer-border-color: #2d2d2d;
--cc-cookie-category-expanded-block-bg: #3d3d3d;
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
--cc-footer-bg: #2d2d2d;
--cc-footer-color: #e5e5e5;
--cc-footer-border-color: #2d2d2d;
}
.cm__body{
max-width: 90% !important;
flex-direction: row !important;
align-items: center !important;
.cm__body {
max-width: 90% !important;
flex-direction: row !important;
align-items: center !important;
}
.cm__desc{
max-width: 70rem !important;
.cm__desc {
max-width: 70rem !important;
}
.cm__btns{
flex-direction: row-reverse !important;
gap:10px !important;
padding-top: 3.4rem !important;
.cm__btns {
flex-direction: row-reverse !important;
gap: 10px !important;
padding-top: 3.4rem !important;
}
@media only screen and (max-width: 1400px) {
.cm__body{
max-width: 90% !important;
flex-direction: column !important;
align-items: normal !important;
}
.cm__body {
max-width: 90% !important;
flex-direction: column !important;
align-items: normal !important;
}
.cm__btns{
padding-top: 1rem !important;
}
.cm__btns {
padding-top: 1rem !important;
}
}
/* Toggle visibility fixes */
#cc-main .section__toggle {
opacity: 0 !important; /* Keep invisible but functional */
opacity: 0 !important; /* Keep invisible but functional */
}
#cc-main .toggle__icon {
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
}
#cc-main .toggle__icon-circle {
display: block !important;
position: absolute !important;
transition: transform 0.25s ease !important;
display: block !important;
position: absolute !important;
transition: transform 0.25s ease !important;
}
#cc-main .toggle__icon-on,
#cc-main .toggle__icon-off {
display: flex !important;
align-items: center !important;
justify-content: center !important;
position: absolute !important;
width: 100% !important;
height: 100% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
position: absolute !important;
width: 100% !important;
height: 100% !important;
}
/* Ensure toggles are visible in both themes */
#cc-main .toggle__icon {
background: var(--cc-toggle-off-bg) !important;
border: 1px solid var(--cc-toggle-off-bg) !important;
background: var(--cc-toggle-off-bg) !important;
border: 1px solid var(--cc-toggle-off-bg) !important;
}
#cc-main .section__toggle:checked ~ .toggle__icon {
background: var(--cc-toggle-on-bg) !important;
border: 1px solid var(--cc-toggle-on-bg) !important;
background: var(--cc-toggle-on-bg) !important;
border: 1px solid var(--cc-toggle-on-bg) !important;
}
/* Ensure toggle text is visible */
#cc-main .pm__section-title {
color: var(--cc-primary-color) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .pm__section-desc {
color: var(--cc-secondary-color) !important;
color: var(--cc-secondary-color) !important;
}
/* Make sure the modal has proper contrast */
#cc-main .pm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
/* Lower z-index so cookie banner appears behind onboarding modals */
#cc-main {
z-index: 100 !important;
z-index: 100 !important;
}
/* Ensure consent modal text is visible in both themes */
#cc-main .cm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__title {
color: var(--cc-primary-color) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__desc {
color: var(--cc-primary-color) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer {
color: var(--cc-primary-color) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer-links a,
#cc-main .cm__link {
color: var(--cc-primary-color) !important;
}
color: var(--cc-primary-color) !important;
}
-1
View File
@@ -23,4 +23,3 @@
"theme_color": "#000000",
"background_color": "#ffffff"
}
+10 -14
View File
@@ -1,28 +1,24 @@
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, copyFileSync } from "node:fs";
import { join, resolve } from "node:path";
if (process.platform !== 'win32') {
if (process.platform !== "win32") {
process.exit(0);
}
const frontendDir = process.cwd();
const tauriDir = resolve(frontendDir, 'src-tauri');
const provisionerManifest = join(tauriDir, 'provisioner', 'Cargo.toml');
const tauriDir = resolve(frontendDir, "src-tauri");
const provisionerManifest = join(tauriDir, "provisioner", "Cargo.toml");
execFileSync(
'cargo',
['build', '--release', '--manifest-path', provisionerManifest],
{ stdio: 'inherit' }
);
execFileSync("cargo", ["build", "--release", "--manifest-path", provisionerManifest], { stdio: "inherit" });
const provisionerExe = join(tauriDir, 'provisioner', 'target', 'release', 'stirling-provisioner.exe');
const provisionerExe = join(tauriDir, "provisioner", "target", "release", "stirling-provisioner.exe");
if (!existsSync(provisionerExe)) {
throw new Error(`Provisioner binary not found at ${provisionerExe}`);
}
const wixDir = join(tauriDir, 'windows', 'wix');
const wixDir = join(tauriDir, "windows", "wix");
mkdirSync(wixDir, { recursive: true });
const destExe = join(wixDir, 'stirling-provision.exe');
const destExe = join(wixDir, "stirling-provision.exe");
copyFileSync(provisionerExe, destExe);
+31 -29
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env node
const { icons } = require('@iconify-json/material-symbols');
const fs = require('fs');
const path = require('path');
const { icons } = require("@iconify-json/material-symbols");
const fs = require("fs");
const path = require("path");
// Check for verbose flag
const isVerbose = process.argv.includes('--verbose') || process.argv.includes('-v');
const isVerbose = process.argv.includes("--verbose") || process.argv.includes("-v");
// Logging functions
const info = (message) => console.log(message);
@@ -18,12 +18,12 @@ const debug = (message) => {
// Function to scan codebase for LocalIcon usage
function scanForUsedIcons() {
const usedIcons = new Set();
const srcDir = path.join(__dirname, '..', 'src');
const srcDir = path.join(__dirname, "..", "src");
info('🔍 Scanning codebase for LocalIcon usage...');
info("🔍 Scanning codebase for LocalIcon usage...");
if (!fs.existsSync(srcDir)) {
console.error('❌ Source directory not found:', srcDir);
console.error("❌ Source directory not found:", srcDir);
process.exit(1);
}
@@ -31,19 +31,19 @@ function scanForUsedIcons() {
function scanDirectory(dir) {
const files = fs.readdirSync(dir);
files.forEach(file => {
files.forEach((file) => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
scanDirectory(filePath);
} else if (file.endsWith('.tsx') || file.endsWith('.ts')) {
const content = fs.readFileSync(filePath, 'utf8');
} else if (file.endsWith(".tsx") || file.endsWith(".ts")) {
const content = fs.readFileSync(filePath, "utf8");
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...>
const localIconMatches = content.match(/<LocalIcon\s+[^>]*icon="([^"]+)"/g);
if (localIconMatches) {
localIconMatches.forEach(match => {
localIconMatches.forEach((match) => {
const iconMatch = match.match(/icon="([^"]+)"/);
if (iconMatch) {
usedIcons.add(iconMatch[1]);
@@ -55,7 +55,7 @@ function scanForUsedIcons() {
// Match LocalIcon usage: <LocalIcon icon='icon-name' ...>
const localIconSingleQuoteMatches = content.match(/<LocalIcon\s+[^>]*icon='([^']+)'/g);
if (localIconSingleQuoteMatches) {
localIconSingleQuoteMatches.forEach(match => {
localIconSingleQuoteMatches.forEach((match) => {
const iconMatch = match.match(/icon='([^']+)'/);
if (iconMatch) {
usedIcons.add(iconMatch[1]);
@@ -67,7 +67,7 @@ function scanForUsedIcons() {
// Match old material-symbols-rounded spans: <span className="material-symbols-rounded">icon-name</span>
const spanMatches = content.match(/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g);
if (spanMatches) {
spanMatches.forEach(match => {
spanMatches.forEach((match) => {
const iconMatch = match.match(/>([^<]+)<\/span>/);
if (iconMatch && iconMatch[1].trim()) {
const iconName = iconMatch[1].trim();
@@ -80,7 +80,7 @@ function scanForUsedIcons() {
// Match Icon component usage: <Icon icon="material-symbols:icon-name" ...>
const iconMatches = content.match(/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g);
if (iconMatches) {
iconMatches.forEach(match => {
iconMatches.forEach((match) => {
const iconMatch = match.match(/icon="material-symbols:([^"]+)"/);
if (iconMatch) {
usedIcons.add(iconMatch[1]);
@@ -92,7 +92,7 @@ function scanForUsedIcons() {
// Match icon config usage: icon: 'icon-name' or icon: "icon-name"
const iconPropertyMatches = content.match(/icon:\s*(['"])([a-z0-9-]+)\1/g);
if (iconPropertyMatches) {
iconPropertyMatches.forEach(match => {
iconPropertyMatches.forEach((match) => {
const iconMatch = match.match(/icon:\s*(['"])([a-z0-9-]+)\1/);
if (iconMatch) {
usedIcons.add(iconMatch[2]);
@@ -118,18 +118,20 @@ async function main() {
const usedIcons = scanForUsedIcons();
// Check if we need to regenerate (compare with existing)
const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json');
const outputPath = path.join(__dirname, "..", "src", "assets", "material-symbols-icons.json");
let needsRegeneration = true;
if (fs.existsSync(outputPath)) {
try {
const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
const existingSet = JSON.parse(fs.readFileSync(outputPath, "utf8"));
const existingIcons = Object.keys(existingSet.icons || {}).sort();
const currentIcons = [...usedIcons].sort();
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
needsRegeneration = false;
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
info(
`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`,
);
}
} catch {
// If we can't parse existing file, regenerate
@@ -138,34 +140,34 @@ async function main() {
}
if (!needsRegeneration) {
info('🎉 No regeneration needed!');
info("🎉 No regeneration needed!");
process.exit(0);
}
info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`);
// Dynamic import of ES module
const { getIcons } = await import('@iconify/utils');
const { getIcons } = await import("@iconify/utils");
// Extract only our used icons from the full set
const extractedIcons = getIcons(icons, usedIcons);
if (!extractedIcons) {
console.error('❌ Failed to extract icons');
console.error("❌ Failed to extract icons");
process.exit(1);
}
// Check for missing icons
const extractedIconNames = Object.keys(extractedIcons.icons || {});
const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon));
const missingIcons = usedIcons.filter((icon) => !extractedIconNames.includes(icon));
if (missingIcons.length > 0) {
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`);
info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.');
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`);
info("💡 These icons don't exist in Material Symbols. Please use available alternatives.");
}
// Create output directory
const outputDir = path.join(__dirname, '..', 'src', 'assets');
const outputDir = path.join(__dirname, "..", "src", "assets");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
@@ -182,7 +184,7 @@ async function main() {
// This file is automatically generated by scripts/generate-icons.js
// Do not edit manually - changes will be overwritten
export type MaterialSymbolIcon = ${usedIcons.map(icon => `'${icon}'`).join(' | ')};
export type MaterialSymbolIcon = ${usedIcons.map((icon) => `'${icon}'`).join(" | ")};
export interface IconSet {
prefix: string;
@@ -196,7 +198,7 @@ declare const iconSet: IconSet;
export default iconSet;
`;
const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts');
const typesPath = path.join(outputDir, "material-symbols-icons.d.ts");
fs.writeFileSync(typesPath, typesContent);
info(`📝 Generated types: ${typesPath}`);
@@ -204,7 +206,7 @@ export default iconSet;
}
// Run the main function
main().catch(error => {
console.error('❌ Script failed:', error);
main().catch((error) => {
console.error("❌ Script failed:", error);
process.exit(1);
});
+396 -370
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env node
const { execSync } = require('node:child_process');
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('node:fs');
const path = require('node:path');
const { execSync } = require("node:child_process");
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require("node:fs");
const path = require("node:path");
const { argv } = require('node:process');
const inputIdx = argv.indexOf('--input');
const { argv } = require("node:process");
const inputIdx = argv.indexOf("--input");
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
const POSTPROCESS_ONLY = !!INPUT_FILE;
@@ -16,408 +16,434 @@ const POSTPROCESS_ONLY = !!INPUT_FILE;
* This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json
*/
const OUTPUT_FILE = path.join(__dirname, '..', 'src', 'assets', '3rdPartyLicenses.json');
const PACKAGE_JSON = path.join(__dirname, '..', 'package.json');
const OUTPUT_FILE = path.join(__dirname, "..", "src", "assets", "3rdPartyLicenses.json");
const PACKAGE_JSON = path.join(__dirname, "..", "package.json");
// Ensure the output directory exists
const outputDir = path.dirname(OUTPUT_FILE);
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
mkdirSync(outputDir, { recursive: true });
}
console.log('🔍 Generating frontend license report...');
console.log("🔍 Generating frontend license report...");
try {
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
if (process.env.PR_IS_FORK === 'true' && !POSTPROCESS_ONLY) {
console.error('Fork PR detected: only --input (postprocess-only) mode is allowed.');
process.exit(2);
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
if (process.env.PR_IS_FORK === "true" && !POSTPROCESS_ONLY) {
console.error("Fork PR detected: only --input (postprocess-only) mode is allowed.");
process.exit(2);
}
let licenseData;
// Generate license report using pinned license-checker; disable lifecycle scripts
if (POSTPROCESS_ONLY) {
if (!INPUT_FILE || !existsSync(INPUT_FILE)) {
console.error("❌ --input file missing or not found");
process.exit(1);
}
let licenseData;
// Generate license report using pinned license-checker; disable lifecycle scripts
if (POSTPROCESS_ONLY) {
if (!INPUT_FILE || !existsSync(INPUT_FILE)) {
console.error('❌ --input file missing or not found');
process.exit(1);
}
licenseData = JSON.parse(readFileSync(INPUT_FILE, 'utf8'));
} else {
const licenseReport = execSync(
// 'npx --yes [email protected] --production --json',
'npx --yes license-report --only=prod --output=json',
{
encoding: 'utf8',
cwd: path.dirname(PACKAGE_JSON),
env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: 'true' }
}
);
try {
licenseData = JSON.parse(licenseReport);
} catch (parseError) {
console.error('❌ Failed to parse license data:', parseError.message);
console.error('Raw output:', licenseReport.substring(0, 500) + '...');
process.exit(1);
}
}
if (!Array.isArray(licenseData)) {
console.error('❌ Invalid license data structure');
process.exit(1);
}
// Convert license-checker format to array
const licenseArray = licenseData.map(dep => {
let licenseType = dep.licenseType;
// Handle missing or null licenses
if (!licenseType || licenseType === null || licenseType === undefined) {
licenseType = 'Unknown';
}
// Handle empty string licenses
if (licenseType === '') {
licenseType = 'Unknown';
}
// Handle array licenses (rare but possible)
if (Array.isArray(licenseType)) {
licenseType = licenseType.join(' AND ');
}
// Handle object licenses (fallback)
if (typeof licenseType === 'object' && licenseType !== null) {
licenseType = 'Unknown';
}
if ( "posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) {
licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
}
return {
name: dep.name,
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || 'unknown',
licenseType: licenseType,
repository: dep.link,
url: dep.link,
link: dep.link
};
});
// Transform to match Java backend format
const transformedData = {
dependencies: licenseArray.map(dep => {
const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
const licenseUrl = dep.link || getLicenseUrl(licenseType);
return {
moduleName: dep.name,
moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
moduleVersion: dep.version,
moduleLicense: licenseType,
moduleLicenseUrl: licenseUrl
};
})
};
// Log summary of license types found
const licenseSummary = licenseArray.reduce((acc, dep) => {
const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
acc[license] = (acc[license] || 0) + 1;
return acc;
}, {});
console.log('📊 License types found:');
Object.entries(licenseSummary).forEach(([license, count]) => {
console.log(` ${license}: ${count} packages`);
});
// Log any complex or unusual license formats for debugging
const complexLicenses = licenseArray.filter(dep =>
dep.licenseType && (
dep.licenseType.includes('AND') ||
dep.licenseType.includes('OR') ||
dep.licenseType === 'Unknown' ||
dep.licenseType.includes('SEE LICENSE')
)
licenseData = JSON.parse(readFileSync(INPUT_FILE, "utf8"));
} else {
const licenseReport = execSync(
// 'npx --yes [email protected] --production --json',
"npx --yes license-report --only=prod --output=json",
{
encoding: "utf8",
cwd: path.dirname(PACKAGE_JSON),
env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: "true" },
},
);
if (complexLicenses.length > 0) {
console.log('\n🔍 Complex/Edge case licenses detected:');
complexLicenses.forEach(dep => {
console.log(` ${dep.name}@${dep.version}: "${dep.licenseType}"`);
});
try {
licenseData = JSON.parse(licenseReport);
} catch (parseError) {
console.error("❌ Failed to parse license data:", parseError.message);
console.error("Raw output:", licenseReport.substring(0, 500) + "...");
process.exit(1);
}
}
// Check for potentially problematic licenses
const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray);
if (problematicLicenses.length > 0) {
console.log('\n⚠️ License compatibility warnings:');
problematicLicenses.forEach(warning => {
console.log(` ${warning.message}`);
});
// Write license warnings to a separate file for CI/CD
const warningsFile = path.join(__dirname, '..', 'src', 'assets', 'license-warnings.json');
writeFileSync(warningsFile, JSON.stringify({
warnings: problematicLicenses,
generated: new Date().toISOString()
}, null, 2));
console.log(`⚠️ License warnings saved to: ${warningsFile}`);
} else {
console.log('\n✅ All licenses appear to be corporate-friendly');
}
// Write to file
writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 4));
console.log(`✅ License report generated successfully!`);
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
console.log(`💾 Saved to: ${OUTPUT_FILE}`);
} catch (error) {
console.error('❌ Error generating license report:', error.message);
if (!Array.isArray(licenseData)) {
console.error("❌ Invalid license data structure");
process.exit(1);
}
// Convert license-checker format to array
const licenseArray = licenseData.map((dep) => {
let licenseType = dep.licenseType;
// Handle missing or null licenses
if (!licenseType || licenseType === null || licenseType === undefined) {
licenseType = "Unknown";
}
// Handle empty string licenses
if (licenseType === "") {
licenseType = "Unknown";
}
// Handle array licenses (rare but possible)
if (Array.isArray(licenseType)) {
licenseType = licenseType.join(" AND ");
}
// Handle object licenses (fallback)
if (typeof licenseType === "object" && licenseType !== null) {
licenseType = "Unknown";
}
if ("posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) {
licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
}
return {
name: dep.name,
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || "unknown",
licenseType: licenseType,
repository: dep.link,
url: dep.link,
link: dep.link,
};
});
// Transform to match Java backend format
const transformedData = {
dependencies: licenseArray.map((dep) => {
const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown";
const licenseUrl = dep.link || getLicenseUrl(licenseType);
return {
moduleName: dep.name,
moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
moduleVersion: dep.version,
moduleLicense: licenseType,
moduleLicenseUrl: licenseUrl,
};
}),
};
// Log summary of license types found
const licenseSummary = licenseArray.reduce((acc, dep) => {
const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown";
acc[license] = (acc[license] || 0) + 1;
return acc;
}, {});
console.log("📊 License types found:");
Object.entries(licenseSummary).forEach(([license, count]) => {
console.log(` ${license}: ${count} packages`);
});
// Log any complex or unusual license formats for debugging
const complexLicenses = licenseArray.filter(
(dep) =>
dep.licenseType &&
(dep.licenseType.includes("AND") ||
dep.licenseType.includes("OR") ||
dep.licenseType === "Unknown" ||
dep.licenseType.includes("SEE LICENSE")),
);
if (complexLicenses.length > 0) {
console.log("\n🔍 Complex/Edge case licenses detected:");
complexLicenses.forEach((dep) => {
console.log(` ${dep.name}@${dep.version}: "${dep.licenseType}"`);
});
}
// Check for potentially problematic licenses
const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray);
if (problematicLicenses.length > 0) {
console.log("\n⚠️ License compatibility warnings:");
problematicLicenses.forEach((warning) => {
console.log(` ${warning.message}`);
});
// Write license warnings to a separate file for CI/CD
const warningsFile = path.join(__dirname, "..", "src", "assets", "license-warnings.json");
writeFileSync(
warningsFile,
JSON.stringify(
{
warnings: problematicLicenses,
generated: new Date().toISOString(),
},
null,
2,
),
);
console.log(`⚠️ License warnings saved to: ${warningsFile}`);
} else {
console.log("\n✅ All licenses appear to be corporate-friendly");
}
// Write to file
writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 2) + "\n");
console.log(`✅ License report generated successfully!`);
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
console.log(`💾 Saved to: ${OUTPUT_FILE}`);
} catch (error) {
console.error("❌ Error generating license report:", error.message);
process.exit(1);
}
/**
* Get standard license URLs for common licenses
*/
function getLicenseUrl(licenseType) {
if (!licenseType || licenseType === 'Unknown') return '';
if (!licenseType || licenseType === "Unknown") return "";
const licenseUrls = {
'MIT': 'https://opensource.org/licenses/MIT',
'MIT*': 'https://opensource.org/licenses/MIT',
'Apache-2.0': 'https://www.apache.org/licenses/LICENSE-2.0',
'Apache License 2.0': 'https://www.apache.org/licenses/LICENSE-2.0',
'BSD-3-Clause': 'https://opensource.org/licenses/BSD-3-Clause',
'BSD-2-Clause': 'https://opensource.org/licenses/BSD-2-Clause',
'BSD': 'https://opensource.org/licenses/BSD-3-Clause',
'GPL-3.0': 'https://www.gnu.org/licenses/gpl-3.0.html',
'GPL-2.0': 'https://www.gnu.org/licenses/gpl-2.0.html',
'LGPL-2.1': 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html',
'LGPL-3.0': 'https://www.gnu.org/licenses/lgpl-3.0.html',
'ISC': 'https://opensource.org/licenses/ISC',
'CC0-1.0': 'https://creativecommons.org/publicdomain/zero/1.0/',
'Unlicense': 'https://unlicense.org/',
'MPL-2.0': 'https://www.mozilla.org/en-US/MPL/2.0/',
'WTFPL': 'http://www.wtfpl.net/',
'Zlib': 'https://opensource.org/licenses/Zlib',
'Artistic-2.0': 'https://opensource.org/licenses/Artistic-2.0',
'EPL-1.0': 'https://www.eclipse.org/legal/epl-v10.html',
'EPL-2.0': 'https://www.eclipse.org/legal/epl-2.0/',
'CDDL-1.0': 'https://opensource.org/licenses/CDDL-1.0',
'Ruby': 'https://www.ruby-lang.org/en/about/license.txt',
'Python-2.0': 'https://www.python.org/download/releases/2.0/license/',
'Public Domain': 'https://creativecommons.org/publicdomain/zero/1.0/',
'UNLICENSED': ''
};
const licenseUrls = {
MIT: "https://opensource.org/licenses/MIT",
"MIT*": "https://opensource.org/licenses/MIT",
"Apache-2.0": "https://www.apache.org/licenses/LICENSE-2.0",
"Apache License 2.0": "https://www.apache.org/licenses/LICENSE-2.0",
"BSD-3-Clause": "https://opensource.org/licenses/BSD-3-Clause",
"BSD-2-Clause": "https://opensource.org/licenses/BSD-2-Clause",
BSD: "https://opensource.org/licenses/BSD-3-Clause",
"GPL-3.0": "https://www.gnu.org/licenses/gpl-3.0.html",
"GPL-2.0": "https://www.gnu.org/licenses/gpl-2.0.html",
"LGPL-2.1": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html",
"LGPL-3.0": "https://www.gnu.org/licenses/lgpl-3.0.html",
ISC: "https://opensource.org/licenses/ISC",
"CC0-1.0": "https://creativecommons.org/publicdomain/zero/1.0/",
Unlicense: "https://unlicense.org/",
"MPL-2.0": "https://www.mozilla.org/en-US/MPL/2.0/",
WTFPL: "http://www.wtfpl.net/",
Zlib: "https://opensource.org/licenses/Zlib",
"Artistic-2.0": "https://opensource.org/licenses/Artistic-2.0",
"EPL-1.0": "https://www.eclipse.org/legal/epl-v10.html",
"EPL-2.0": "https://www.eclipse.org/legal/epl-2.0/",
"CDDL-1.0": "https://opensource.org/licenses/CDDL-1.0",
Ruby: "https://www.ruby-lang.org/en/about/license.txt",
"Python-2.0": "https://www.python.org/download/releases/2.0/license/",
"Public Domain": "https://creativecommons.org/publicdomain/zero/1.0/",
UNLICENSED: "",
};
// Try exact match first
if (licenseUrls[licenseType]) {
return licenseUrls[licenseType];
// Try exact match first
if (licenseUrls[licenseType]) {
return licenseUrls[licenseType];
}
// Try case-insensitive match
const lowerType = licenseType.toLowerCase();
for (const [key, url] of Object.entries(licenseUrls)) {
if (key.toLowerCase() === lowerType) {
return url;
}
}
// Try case-insensitive match
const lowerType = licenseType.toLowerCase();
for (const [key, url] of Object.entries(licenseUrls)) {
if (key.toLowerCase() === lowerType) {
return url;
}
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (licenseType.includes("AND") || licenseType.includes("OR")) {
// Extract the first license from compound expressions for URL
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
if (match && licenseUrls[match[1]]) {
return licenseUrls[match[1]];
}
}
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (licenseType.includes('AND') || licenseType.includes('OR')) {
// Extract the first license from compound expressions for URL
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
if (match && licenseUrls[match[1]]) {
return licenseUrls[match[1]];
}
}
// For non-standard licenses, return empty string (will use package link if available)
return '';
// For non-standard licenses, return empty string (will use package link if available)
return "";
}
/**
* Check for potentially problematic licenses that may not be MIT/corporate compatible
*/
function checkLicenseCompatibility(licenseSummary, licenseArray) {
const warnings = [];
const warnings = [];
// Define problematic license patterns
const problematicLicenses = {
// Copyleft licenses
'GPL-2.0': 'Strong copyleft license - requires derivative works to be GPL',
'GPL-3.0': 'Strong copyleft license - requires derivative works to be GPL',
'LGPL-2.1': 'Weak copyleft license - may require source disclosure for modifications',
'LGPL-3.0': 'Weak copyleft license - may require source disclosure for modifications',
'AGPL-3.0': 'Network copyleft license - requires source disclosure for network use',
'AGPL-1.0': 'Network copyleft license - requires source disclosure for network use',
// Define problematic license patterns
const problematicLicenses = {
// Copyleft licenses
"GPL-2.0": "Strong copyleft license - requires derivative works to be GPL",
"GPL-3.0": "Strong copyleft license - requires derivative works to be GPL",
"LGPL-2.1": "Weak copyleft license - may require source disclosure for modifications",
"LGPL-3.0": "Weak copyleft license - may require source disclosure for modifications",
"AGPL-3.0": "Network copyleft license - requires source disclosure for network use",
"AGPL-1.0": "Network copyleft license - requires source disclosure for network use",
// Other potentially problematic licenses
'WTFPL': 'Potentially problematic license - legal uncertainty',
'CC-BY-SA-4.0': 'ShareAlike license - requires derivative works to use same license',
'CC-BY-SA-3.0': 'ShareAlike license - requires derivative works to use same license',
'CC-BY-NC-4.0': 'Non-commercial license - prohibits commercial use',
'CC-BY-NC-3.0': 'Non-commercial license - prohibits commercial use',
'OSL-3.0': 'Copyleft license - requires derivative works to be OSL',
'EPL-1.0': 'Weak copyleft license - may require source disclosure',
'EPL-2.0': 'Weak copyleft license - may require source disclosure',
'CDDL-1.0': 'Weak copyleft license - may require source disclosure',
'CDDL-1.1': 'Weak copyleft license - may require source disclosure',
'CPL-1.0': 'Weak copyleft license - may require source disclosure',
'MPL-1.1': 'Weak copyleft license - may require source disclosure',
'EUPL-1.1': 'Copyleft license - requires derivative works to be EUPL',
'EUPL-1.2': 'Copyleft license - requires derivative works to be EUPL',
'UNLICENSED': 'No license specified - usage rights unclear',
'Unknown': 'License not detected - manual review required'
};
// Other potentially problematic licenses
WTFPL: "Potentially problematic license - legal uncertainty",
"CC-BY-SA-4.0": "ShareAlike license - requires derivative works to use same license",
"CC-BY-SA-3.0": "ShareAlike license - requires derivative works to use same license",
"CC-BY-NC-4.0": "Non-commercial license - prohibits commercial use",
"CC-BY-NC-3.0": "Non-commercial license - prohibits commercial use",
"OSL-3.0": "Copyleft license - requires derivative works to be OSL",
"EPL-1.0": "Weak copyleft license - may require source disclosure",
"EPL-2.0": "Weak copyleft license - may require source disclosure",
"CDDL-1.0": "Weak copyleft license - may require source disclosure",
"CDDL-1.1": "Weak copyleft license - may require source disclosure",
"CPL-1.0": "Weak copyleft license - may require source disclosure",
"MPL-1.1": "Weak copyleft license - may require source disclosure",
"EUPL-1.1": "Copyleft license - requires derivative works to be EUPL",
"EUPL-1.2": "Copyleft license - requires derivative works to be EUPL",
UNLICENSED: "No license specified - usage rights unclear",
Unknown: "License not detected - manual review required",
};
// Known good licenses (no warnings needed)
const goodLicenses = new Set([
'MIT', 'MIT*', 'Apache-2.0', 'Apache License 2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD',
'ISC', 'CC0-1.0', 'Public Domain', 'Unlicense', '0BSD', 'BlueOak-1.0.0',
'Zlib', 'Artistic-2.0', 'Python-2.0', 'Ruby', 'MPL-2.0', 'CC-BY-4.0',
'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE',
'SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE'
]);
// Known good licenses (no warnings needed)
const goodLicenses = new Set([
"MIT",
"MIT*",
"Apache-2.0",
"Apache License 2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"BSD",
"ISC",
"CC0-1.0",
"Public Domain",
"Unlicense",
"0BSD",
"BlueOak-1.0.0",
"Zlib",
"Artistic-2.0",
"Python-2.0",
"Ruby",
"MPL-2.0",
"CC-BY-4.0",
"SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
]);
// Helper function to normalize license names for comparison
function normalizeLicense(license) {
return license
.replace(/-or-later$/, '') // Remove -or-later suffix
.replace(/\+$/, '') // Remove + suffix
.trim();
// Helper function to normalize license names for comparison
function normalizeLicense(license) {
return license
.replace(/-or-later$/, "") // Remove -or-later suffix
.replace(/\+$/, "") // Remove + suffix
.trim();
}
// Check each license type
Object.entries(licenseSummary).forEach(([license, count]) => {
// Skip known good licenses
if (goodLicenses.has(license)) {
return;
}
// Check each license type
Object.entries(licenseSummary).forEach(([license, count]) => {
// Skip known good licenses
if (goodLicenses.has(license)) {
return;
}
// Check if this license only affects our own packages
const affectedPackages = licenseArray.filter(dep => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
return depLicense === license;
});
const isOnlyOurPackages = affectedPackages.every(dep =>
dep.name === 'frontend' ||
dep.name.toLowerCase().includes('stirling-pdf') ||
dep.name.toLowerCase().includes('stirling_pdf') ||
dep.name.toLowerCase().includes('stirlingpdf')
);
if (isOnlyOurPackages && (license === 'UNLICENSED' || license.startsWith('SEE LICENSE IN'))) {
return; // Skip warnings for our own Stirling-PDF packages
}
// Check for compound licenses like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (license.includes('AND') || license.includes('OR')) {
// For OR licenses, check if there's at least one acceptable license option
if (license.includes('OR')) {
// Extract license components from OR expression
const orComponents = license
.replace(/[()]/g, '') // Remove parentheses
.split(' OR ')
.map(component => component.trim());
// Check if any component is in the goodLicenses set (with normalization)
const hasGoodLicense = orComponents.some(component => {
const normalized = normalizeLicense(component);
return goodLicenses.has(component) || goodLicenses.has(normalized);
});
if (hasGoodLicense) {
return; // Skip warning - can use the good license option
}
}
// For AND licenses or OR licenses with no good options, check for problematic components
const hasProblematicComponent = Object.keys(problematicLicenses).some(problematic =>
license.includes(problematic)
);
if (hasProblematicComponent) {
const affectedPackages = licenseArray
.filter(dep => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
return depLicense === license;
})
.map(dep => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
}));
const licenseType = license.includes('AND') ? 'AND' : 'OR';
const reason = licenseType === 'AND'
? 'Compound license with AND requirement - all components must be compatible'
: 'Compound license with potentially problematic components and no good fallback options';
warnings.push({
message: `📋 This PR contains ${count} package${count > 1 ? 's' : ''} with compound license "${license}" - manual review recommended`,
licenseType: license,
licenseUrl: '',
reason: reason,
packageCount: count,
affectedDependencies: affectedPackages
});
}
return;
}
// Check for exact matches with problematic licenses
if (problematicLicenses[license]) {
const affectedPackages = licenseArray
.filter(dep => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
return depLicense === license;
})
.map(dep => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
}));
const packageList = affectedPackages.map(pkg => pkg.name).slice(0, 5).join(', ') + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : '');
const licenseUrl = getLicenseUrl(license) || 'https://opensource.org/licenses';
warnings.push({
message: `⚠️ This PR contains ${count} package${count > 1 ? 's' : ''} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`,
licenseType: license,
licenseUrl: licenseUrl,
reason: problematicLicenses[license],
packageCount: count,
affectedDependencies: affectedPackages
});
} else {
// Unknown license type - flag for manual review
const affectedPackages = licenseArray
.filter(dep => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
return depLicense === license;
})
.map(dep => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
}));
warnings.push({
message: `❓ This PR contains ${count} package${count > 1 ? 's' : ''} with unknown license type "${license}" - manual review required`,
licenseType: license,
licenseUrl: '',
reason: 'Unknown license type',
packageCount: count,
affectedDependencies: affectedPackages
});
}
// Check if this license only affects our own packages
const affectedPackages = licenseArray.filter((dep) => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
return depLicense === license;
});
return warnings;
const isOnlyOurPackages = affectedPackages.every(
(dep) =>
dep.name === "frontend" ||
dep.name.toLowerCase().includes("stirling-pdf") ||
dep.name.toLowerCase().includes("stirling_pdf") ||
dep.name.toLowerCase().includes("stirlingpdf"),
);
if (isOnlyOurPackages && (license === "UNLICENSED" || license.startsWith("SEE LICENSE IN"))) {
return; // Skip warnings for our own Stirling-PDF packages
}
// Check for compound licenses like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (license.includes("AND") || license.includes("OR")) {
// For OR licenses, check if there's at least one acceptable license option
if (license.includes("OR")) {
// Extract license components from OR expression
const orComponents = license
.replace(/[()]/g, "") // Remove parentheses
.split(" OR ")
.map((component) => component.trim());
// Check if any component is in the goodLicenses set (with normalization)
const hasGoodLicense = orComponents.some((component) => {
const normalized = normalizeLicense(component);
return goodLicenses.has(component) || goodLicenses.has(normalized);
});
if (hasGoodLicense) {
return; // Skip warning - can use the good license option
}
}
// For AND licenses or OR licenses with no good options, check for problematic components
const hasProblematicComponent = Object.keys(problematicLicenses).some((problematic) => license.includes(problematic));
if (hasProblematicComponent) {
const affectedPackages = licenseArray
.filter((dep) => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
return depLicense === license;
})
.map((dep) => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
}));
const licenseType = license.includes("AND") ? "AND" : "OR";
const reason =
licenseType === "AND"
? "Compound license with AND requirement - all components must be compatible"
: "Compound license with potentially problematic components and no good fallback options";
warnings.push({
message: `📋 This PR contains ${count} package${count > 1 ? "s" : ""} with compound license "${license}" - manual review recommended`,
licenseType: license,
licenseUrl: "",
reason: reason,
packageCount: count,
affectedDependencies: affectedPackages,
});
}
return;
}
// Check for exact matches with problematic licenses
if (problematicLicenses[license]) {
const affectedPackages = licenseArray
.filter((dep) => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
return depLicense === license;
})
.map((dep) => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
}));
const packageList =
affectedPackages
.map((pkg) => pkg.name)
.slice(0, 5)
.join(", ") + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : "");
const licenseUrl = getLicenseUrl(license) || "https://opensource.org/licenses";
warnings.push({
message: `⚠️ This PR contains ${count} package${count > 1 ? "s" : ""} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`,
licenseType: license,
licenseUrl: licenseUrl,
reason: problematicLicenses[license],
packageCount: count,
affectedDependencies: affectedPackages,
});
} else {
// Unknown license type - flag for manual review
const affectedPackages = licenseArray
.filter((dep) => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
return depLicense === license;
})
.map((dep) => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
}));
warnings.push({
message: `❓ This PR contains ${count} package${count > 1 ? "s" : ""} with unknown license type "${license}" - manual review required`,
licenseType: license,
licenseUrl: "",
reason: "Unknown license type",
packageCount: count,
affectedDependencies: affectedPackages,
});
}
});
return warnings;
}
+25 -26
View File
@@ -8,20 +8,20 @@
* for users to experiment with Stirling PDF's features.
*/
import puppeteer from 'puppeteer';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { existsSync, mkdirSync, statSync } from 'fs';
import puppeteer from "puppeteer";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { existsSync, mkdirSync, statSync } from "fs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const TEMPLATE_PATH = join(__dirname, 'template.html');
const OUTPUT_DIR = join(__dirname, '../../public/samples');
const OUTPUT_PATH = join(OUTPUT_DIR, 'Sample.pdf');
const TEMPLATE_PATH = join(__dirname, "template.html");
const OUTPUT_DIR = join(__dirname, "../../public/samples");
const OUTPUT_PATH = join(OUTPUT_DIR, "Sample.pdf");
async function generatePDF() {
console.log('🚀 Starting Stirling PDF sample document generation...\n');
console.log("🚀 Starting Stirling PDF sample document generation...\n");
// Ensure output directory exists
if (!existsSync(OUTPUT_DIR)) {
@@ -40,66 +40,65 @@ async function generatePDF() {
let browser;
try {
// Launch Puppeteer
console.log('🌐 Launching browser...');
console.log("🌐 Launching browser...");
browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
headless: "new",
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
// Set viewport to match A4 proportions
await page.setViewport({
width: 794, // A4 width in pixels at 96 DPI
width: 794, // A4 width in pixels at 96 DPI
height: 1123, // A4 height in pixels at 96 DPI
deviceScaleFactor: 2 // Higher quality rendering
deviceScaleFactor: 2, // Higher quality rendering
});
// Navigate to the template file
const fileUrl = `file://${TEMPLATE_PATH}`;
console.log('📖 Loading HTML template...');
console.log("📖 Loading HTML template...");
await page.goto(fileUrl, {
waitUntil: 'networkidle0' // Wait for all resources to load
waitUntil: "networkidle0", // Wait for all resources to load
});
// Generate PDF with A4 dimensions
console.log('📝 Generating PDF...');
console.log("📝 Generating PDF...");
await page.pdf({
path: OUTPUT_PATH,
format: 'A4',
format: "A4",
printBackground: true,
margin: {
top: 0,
right: 0,
bottom: 0,
left: 0
left: 0,
},
preferCSSPageSize: true
preferCSSPageSize: true,
});
console.log('\n✅ PDF generated successfully!');
console.log("\n✅ PDF generated successfully!");
console.log(`📦 Output: ${OUTPUT_PATH}`);
// Get file size
const stats = statSync(OUTPUT_PATH);
const fileSizeInKB = (stats.size / 1024).toFixed(2);
console.log(`📊 File size: ${fileSizeInKB} KB`);
} catch (error) {
console.error('\n❌ Error generating PDF:', error.message);
console.error("\n❌ Error generating PDF:", error.message);
process.exit(1);
} finally {
if (browser) {
await browser.close();
console.log('🔒 Browser closed.');
console.log("🔒 Browser closed.");
}
}
console.log('\n🎉 Done! Sample PDF is ready for use in Stirling PDF.\n');
console.log("\n🎉 Done! Sample PDF is ready for use in Stirling PDF.\n");
}
// Run the generator
generatePDF().catch(error => {
console.error('Fatal error:', error);
generatePDF().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
+3 -2
View File
@@ -20,8 +20,9 @@
--color-white: #ffffff;
/* Font Stack */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Helvetica Neue", sans-serif;
}
* {
+210 -200
View File
@@ -1,234 +1,244 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stirling PDF - Sample Document</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Page 1: Hero / Cover Page -->
<div class="page page-1">
<div class="decorative-shapes">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" alt="">
</div>
<div class="hero-content">
<div class="logo-container">
<img src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stirling PDF - Sample Document</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<!-- Page 1: Hero / Cover Page -->
<div class="page page-1">
<div class="decorative-shapes">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="" />
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="" />
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="" />
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="" />
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" alt="" />
</div>
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
<div class="hero-stats">
<div class="stat-badge">
<span class="stat-number">10M+</span>
<span class="stat-label">Downloads</span>
<div class="hero-content">
<div class="logo-container">
<img src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo" />
</div>
</div>
<div class="hero-features">
<div class="feature-pill">Open Source</div>
<div class="feature-pill">Privacy First</div>
<div class="feature-pill">Self-Hosted</div>
</div>
</div>
</div>
<!-- Page 2: What is Stirling PDF -->
<div class="page page-2">
<div class="content-wrapper">
<h2 class="page-title">What is Stirling PDF?</h2>
<p class="intro-text">
Stirling PDF is a robust, web-based PDF manipulation tool.
It enables you to carry out various operations on PDF files, including splitting,
merging, converting, rearranging, adding images, rotating, compressing, and more.
</p>
<div class="value-props">
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
<div class="hero-stats">
<div class="stat-badge">
<span class="stat-number">10M+</span>
<span class="stat-label">Downloads</span>
</div>
<h3>50+ PDF Operations</h3>
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2" />
</svg>
</div>
<h3>Workflow Automation</h3>
<p>Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<h3>Multi-Language Support</h3>
<p>Available in over 30 languages with community-contributed translations. Accessible to users worldwide.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="5" y="11" width="14" height="10" rx="2" />
<circle cx="12" cy="16" r="1" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
</div>
<h3>Privacy First</h3>
<p>Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<h3>Open Source</h3>
<p>Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
</div>
<h3>API Access</h3>
<p>RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.</p>
<div class="hero-features">
<div class="feature-pill">Open Source</div>
<div class="feature-pill">Privacy First</div>
<div class="feature-pill">Self-Hosted</div>
</div>
</div>
</div>
</div>
<!-- Page 3: Key Features -->
<div class="page page-3">
<div class="content-wrapper">
<h2 class="page-title">Key Features</h2>
<!-- Page 2: What is Stirling PDF -->
<div class="page page-2">
<div class="content-wrapper">
<h2 class="page-title">What is Stirling PDF?</h2>
<p class="intro-text">
Stirling PDF is a robust, web-based PDF manipulation tool. It enables you to carry out various operations on PDF
files, including splitting, merging, converting, rearranging, adding images, rotating, compressing, and more.
</p>
<div class="features-grid">
<div class="feature-card" data-category="general">
<div class="feature-header">
<div class="feature-icon-large">
<div class="value-props">
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
</div>
<h3>Page Operations</h3>
<h3>50+ PDF Operations</h3>
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
</div>
<ul class="feature-list">
<li>Merge & split PDFs</li>
<li>Rearrange pages</li>
<li>Rotate & crop</li>
<li>Extract pages</li>
<li>Multi-page layout</li>
</ul>
</div>
<div class="feature-card" data-category="security">
<div class="feature-header">
<div class="feature-icon-large">
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2"
/>
</svg>
</div>
<h3>Workflow Automation</h3>
<p>Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<h3>Security & Signing</h3>
<h3>Multi-Language Support</h3>
<p>Available in over 30 languages with community-contributed translations. Accessible to users worldwide.</p>
</div>
<ul class="feature-list">
<li>Password protection</li>
<li>Digital signatures</li>
<li>Watermarks</li>
<li>Permission controls</li>
<li>Redaction tools</li>
</ul>
</div>
<div class="feature-card" data-category="formatting">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="m5.825 17l1.9 1.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-3.6-3.6q-.15-.15-.213-.325T2.426 16t.063-.375t.212-.325l3.6-3.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L5.825 15H20q.425 0 .713.288T21 16t-.288.713T20 17zm12.35-8H4q-.425 0-.712-.288T3 8t.288-.712T4 7h14.175l-1.9-1.9q-.3-.3-.287-.7t.312-.7q.3-.275.7-.288t.7.288l3.6 3.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.6 3.6q-.275.275-.687.275T16.3 12.3q-.3-.3-.3-.712t.3-.713z" />
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="5" y="11" width="14" height="10" rx="2" />
<circle cx="12" cy="16" r="1" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
</div>
<h3>File Conversions</h3>
<h3>Privacy First</h3>
<p>
Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.
</p>
</div>
<ul class="feature-list">
<li>PDF to/from images</li>
<li>Office documents</li>
<li>HTML to PDF</li>
<li>Markdown to PDF</li>
<li>PDF to Word/Excel</li>
</ul>
</div>
<div class="feature-card" data-category="automation">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2" />
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<h3>Automation</h3>
<h3>Open Source</h3>
<p>Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.</p>
</div>
<ul class="feature-list">
<li>Multi-step workflows</li>
<li>Chain PDF operations</li>
<li>Save recurring tasks</li>
<li>Batch file processing</li>
<li>API integration</li>
</ul>
</div>
</div>
<div class="additional-features">
<div class="additional-features-header">
<div class="additional-features-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 20q-.825 0-1.4125-.5875T4 18t.5875-1.4125T6 16t1.4125.5875T8 18t-.5875 1.4125T6 20m6 0q-.825 0-1.4125-.5875T10 18t.5875-1.4125T12 16t1.4125.5875T14 18t-.5875 1.4125T12 20m6 0q-.825 0-1.4125-.5875T16 18t.5875-1.4125T18 16t1.4125.5875T20 18t-.5875 1.4125T18 20M6 14q-.825 0-1.4125-.5875T4 12t.5875-1.4125T6 10t1.4125.5875T8 12t-.5875 1.4125T6 14m6 0q-.825 0-1.4125-.5875T10 12t.5875-1.4125T12 10t1.4125.5875T14 12t-.5875 1.4125T12 14m6 0q-.825 0-1.4125-.5875T16 12t.5875-1.4125T18 10t1.4125.5875T20 12t-.5875 1.4125T18 14M6 8q-.825 0-1.4125-.5875T4 6t.5875-1.4125T6 4t1.4125.5875T8 6t-.5875 1.4125T6 8m6 0q-.825 0-1.4125-.5875T10 6t.5875-1.4125T12 4t1.4125.5875T14 6t-.5875 1.4125T12 8m6 0q-.825 0-1.4125-.5875T16 6t.5875-1.4125T18 4t1.4125.5875T20 6t-.5875 1.4125T18 8" />
</svg>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
</div>
<h3>API Access</h3>
<p>RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.</p>
</div>
<h3>Plus Many More</h3>
</div>
<div class="additional-features-grid">
<ul>
<li>OCR text recognition</li>
<li>Compress PDFs</li>
<li>Add images & stamps</li>
<li>Detect blank pages</li>
<li>Extract images</li>
<li>Edit metadata</li>
</ul>
<ul>
<li>Flatten forms</li>
<li>PDF/A conversion</li>
<li>Add page numbers</li>
<li>Remove pages</li>
<li>Repair PDFs</li>
<li>And 40+ more tools</li>
</ul>
</div>
</div>
</div>
</div>
</body>
<!-- Page 3: Key Features -->
<div class="page page-3">
<div class="content-wrapper">
<h2 class="page-title">Key Features</h2>
<div class="features-grid">
<div class="feature-card" data-category="general">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<h3>Page Operations</h3>
</div>
<ul class="feature-list">
<li>Merge & split PDFs</li>
<li>Rearrange pages</li>
<li>Rotate & crop</li>
<li>Extract pages</li>
<li>Multi-page layout</li>
</ul>
</div>
<div class="feature-card" data-category="security">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h3>Security & Signing</h3>
</div>
<ul class="feature-list">
<li>Password protection</li>
<li>Digital signatures</li>
<li>Watermarks</li>
<li>Permission controls</li>
<li>Redaction tools</li>
</ul>
</div>
<div class="feature-card" data-category="formatting">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="m5.825 17l1.9 1.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-3.6-3.6q-.15-.15-.213-.325T2.426 16t.063-.375t.212-.325l3.6-3.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L5.825 15H20q.425 0 .713.288T21 16t-.288.713T20 17zm12.35-8H4q-.425 0-.712-.288T3 8t.288-.712T4 7h14.175l-1.9-1.9q-.3-.3-.287-.7t.312-.7q.3-.275.7-.288t.7.288l3.6 3.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.6 3.6q-.275.275-.687.275T16.3 12.3q-.3-.3-.3-.712t.3-.713z"
/>
</svg>
</div>
<h3>File Conversions</h3>
</div>
<ul class="feature-list">
<li>PDF to/from images</li>
<li>Office documents</li>
<li>HTML to PDF</li>
<li>Markdown to PDF</li>
<li>PDF to Word/Excel</li>
</ul>
</div>
<div class="feature-card" data-category="automation">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2"
/>
</svg>
</div>
<h3>Automation</h3>
</div>
<ul class="feature-list">
<li>Multi-step workflows</li>
<li>Chain PDF operations</li>
<li>Save recurring tasks</li>
<li>Batch file processing</li>
<li>API integration</li>
</ul>
</div>
</div>
<div class="additional-features">
<div class="additional-features-header">
<div class="additional-features-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M6 20q-.825 0-1.4125-.5875T4 18t.5875-1.4125T6 16t1.4125.5875T8 18t-.5875 1.4125T6 20m6 0q-.825 0-1.4125-.5875T10 18t.5875-1.4125T12 16t1.4125.5875T14 18t-.5875 1.4125T12 20m6 0q-.825 0-1.4125-.5875T16 18t.5875-1.4125T18 16t1.4125.5875T20 18t-.5875 1.4125T18 20M6 14q-.825 0-1.4125-.5875T4 12t.5875-1.4125T6 10t1.4125.5875T8 12t-.5875 1.4125T6 14m6 0q-.825 0-1.4125-.5875T10 12t.5875-1.4125T12 10t1.4125.5875T14 12t-.5875 1.4125T12 14m6 0q-.825 0-1.4125-.5875T16 12t.5875-1.4125T18 10t1.4125.5875T20 12t-.5875 1.4125T18 14M6 8q-.825 0-1.4125-.5875T4 6t.5875-1.4125T6 4t1.4125.5875T8 6t-.5875 1.4125T6 8m6 0q-.825 0-1.4125-.5875T10 6t.5875-1.4125T12 4t1.4125.5875T14 6t-.5875 1.4125T12 8m6 0q-.825 0-1.4125-.5875T16 6t.5875-1.4125T18 4t1.4125.5875T20 6t-.5875 1.4125T18 8"
/>
</svg>
</div>
<h3>Plus Many More</h3>
</div>
<div class="additional-features-grid">
<ul>
<li>OCR text recognition</li>
<li>Compress PDFs</li>
<li>Add images & stamps</li>
<li>Detect blank pages</li>
<li>Extract images</li>
<li>Edit metadata</li>
</ul>
<ul>
<li>Flatten forms</li>
<li>PDF/A conversion</li>
<li>Add page numbers</li>
<li>Remove pages</li>
<li>Repair PDFs</li>
<li>And 40+ more tools</li>
</ul>
</div>
</div>
</div>
</div>
</body>
</html>
+20 -21
View File
@@ -10,22 +10,22 @@
* tsx scripts/setup-env.ts --saas # also checks .env.saas
*/
import { existsSync, copyFileSync, readFileSync } from 'fs';
import { join } from 'path';
import { config, parse } from 'dotenv';
import { existsSync, copyFileSync, readFileSync } from "fs";
import { join } from "path";
import { config, parse } from "dotenv";
// npm scripts run from the directory containing package.json (frontend/)
const root = process.cwd();
const args = process.argv.slice(2);
const isDesktop = args.includes('--desktop');
const isSaas = args.includes('--saas');
const isDesktop = args.includes("--desktop");
const isSaas = args.includes("--saas");
console.log('setup-env: see frontend/README.md#environment-variables for documentation');
console.log("setup-env: see frontend/README.md#environment-variables for documentation");
function getExampleKeys(exampleFile: string): string[] {
const examplePath = join(root, exampleFile);
if (!existsSync(examplePath)) return [];
return Object.keys(parse(readFileSync(examplePath, 'utf-8')));
return Object.keys(parse(readFileSync(examplePath, "utf-8")));
}
function ensureEnvFile(envFile: string, exampleFile: string): boolean {
@@ -44,13 +44,13 @@ function ensureEnvFile(envFile: string, exampleFile: string): boolean {
config({ path: envPath });
const missing = getExampleKeys(exampleFile).filter(k => !(k in process.env));
const missing = getExampleKeys(exampleFile).filter((k) => !(k in process.env));
if (missing.length > 0) {
console.error(
`setup-env: ${envFile} is missing keys from ${exampleFile}:\n` +
missing.map(k => ` ${k}`).join('\n') +
'\n Add them manually or delete your local file to re-copy from the example.'
missing.map((k) => ` ${k}`).join("\n") +
"\n Add them manually or delete your local file to re-copy from the example.",
);
return true;
}
@@ -59,29 +59,28 @@ function ensureEnvFile(envFile: string, exampleFile: string): boolean {
}
let failed = false;
failed = ensureEnvFile('.env', 'config/.env.example') || failed;
failed = ensureEnvFile(".env", "config/.env.example") || failed;
if (isDesktop) {
failed = ensureEnvFile('.env.desktop', 'config/.env.desktop.example') || failed;
failed = ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed;
}
if (isSaas) {
failed = ensureEnvFile('.env.saas', 'config/.env.saas.example') || failed;
failed = ensureEnvFile(".env.saas", "config/.env.saas.example") || failed;
}
// Warn about any VITE_ vars set in the environment that aren't listed in any example file.
const allExampleKeys = new Set([
...getExampleKeys('config/.env.example'),
...getExampleKeys('config/.env.desktop.example'),
...getExampleKeys('config/.env.saas.example'),
...getExampleKeys("config/.env.example"),
...getExampleKeys("config/.env.desktop.example"),
...getExampleKeys("config/.env.saas.example"),
]);
const unknownViteVars = Object.keys(process.env)
.filter(k => k.startsWith('VITE_') && !allExampleKeys.has(k));
const unknownViteVars = Object.keys(process.env).filter((k) => k.startsWith("VITE_") && !allExampleKeys.has(k));
if (unknownViteVars.length > 0) {
console.warn(
'setup-env: the following VITE_ vars are set but not listed in any example file:\n' +
unknownViteVars.map(k => ` ${k}`).join('\n') +
'\n Add them to the appropriate config/.env.*.example file if they are required.'
"setup-env: the following VITE_ vars are set but not listed in any example file:\n" +
unknownViteVars.map((k) => ` ${k}`).join("\n") +
"\n Add them to the appropriate config/.env.*.example file if they are required.",
);
}
+1 -3
View File
@@ -2,9 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-destroy",
+77 -93
View File
@@ -1,98 +1,82 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.9.2",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev -- --mode desktop",
"beforeBuildCommand": "node scripts/build-provisioner.mjs && npm run build -- --mode desktop"
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.9.2",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev -- --mode desktop",
"beforeBuildCommand": "node scripts/build-provisioner.mjs && npm run build -- --mode desktop"
},
"app": {
"windows": [
{
"title": "Stirling-PDF",
"width": 1280,
"height": 800,
"resizable": true,
"fullscreen": false,
"additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature"
}
]
},
"bundle": {
"active": true,
"publisher": "Stirling PDF Inc.",
"targets": ["deb", "rpm", "dmg", "msi"],
"icon": [
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/16x16.png",
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/192x192.png"
],
"resources": ["libs/*.jar", "runtime/jre/**/*"],
"fileAssociations": [
{
"ext": ["pdf"],
"name": "PDF Document",
"role": "Editor",
"mimeType": "application/pdf"
}
],
"linux": {
"deb": {
"desktopTemplate": "stirling-pdf.desktop"
}
},
"app": {
"windows": [
{
"title": "Stirling-PDF",
"width": 1280,
"height": 800,
"resizable": true,
"fullscreen": false,
"additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature"
}
]
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com",
"wix": {
"fragmentPaths": ["windows/wix/provisioning.wxs"],
"componentGroupRefs": ["ProvisioningComponentGroup"]
}
},
"bundle": {
"active": true,
"publisher": "Stirling PDF Inc.",
"targets": [
"deb",
"rpm",
"dmg",
"msi"
],
"icon": [
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/16x16.png",
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/192x192.png"
],
"resources": [
"libs/*.jar",
"runtime/jre/**/*"
],
"fileAssociations": [
{
"ext": [
"pdf"
],
"name": "PDF Document",
"role": "Editor",
"mimeType": "application/pdf"
}
],
"linux": {
"deb": {
"desktopTemplate": "stirling-pdf.desktop"
}
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com",
"wix": {
"fragmentPaths": [
"windows/wix/provisioning.wxs"
],
"componentGroupRefs": [
"ProvisioningComponentGroup"
]
}
},
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": null,
"entitlements": null,
"providerShortName": null,
"infoPlist": "Info.plist"
}
},
"plugins": {
"shell": {
"open": true
},
"fs": {
"requireLiteralLeadingDot": false
},
"deep-link": {
"desktop": {
"schemes": [
"stirlingpdf"
]
}
}
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": null,
"entitlements": null,
"providerShortName": null,
"infoPlist": "Info.plist"
}
},
"plugins": {
"shell": {
"open": true
},
"fs": {
"requireLiteralLeadingDot": false
},
"deep-link": {
"desktop": {
"schemes": ["stirlingpdf"]
}
}
}
}
+325 -325
View File
@@ -1,326 +1,326 @@
{
"dependencies": [
{
"moduleName": "@atlaskit/pragmatic-drag-and-drop",
"moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git",
"moduleVersion": "1.7.7",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
},
{
"moduleName": "@embedpdf/core",
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
},
{
"moduleName": "@embedpdf/engines",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-annotation",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-export",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-history",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-interaction-manager",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-loader",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-pan",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-render",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-rotate",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-scroll",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-search",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-selection",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-spread",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-thumbnail",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-tiling",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-viewport",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-zoom",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@emotion/react",
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
"moduleVersion": "11.14.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
},
{
"moduleName": "@emotion/styled",
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
"moduleVersion": "11.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
},
{
"moduleName": "@iconify/react",
"moduleUrl": "git+https://github.com/iconify/iconify.git",
"moduleVersion": "6.0.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/iconify/iconify.git"
},
{
"moduleName": "@mantine/core",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/dates",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/dropzone",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/hooks",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mui/icons-material",
"moduleUrl": "git+https://github.com/mui/material-ui.git",
"moduleVersion": "7.3.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
},
{
"moduleName": "@mui/material",
"moduleUrl": "git+https://github.com/mui/material-ui.git",
"moduleVersion": "7.3.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
},
{
"moduleName": "@tailwindcss/postcss",
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
"moduleVersion": "4.1.13",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
},
{
"moduleName": "@tanstack/react-virtual",
"moduleUrl": "git+https://github.com/TanStack/virtual.git",
"moduleVersion": "3.13.12",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git"
},
{
"moduleName": "autoprefixer",
"moduleUrl": "git+https://github.com/postcss/autoprefixer.git",
"moduleVersion": "10.4.21",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git"
},
{
"moduleName": "axios",
"moduleUrl": "git+https://github.com/axios/axios.git",
"moduleVersion": "1.12.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/axios/axios.git"
},
{
"moduleName": "i18next",
"moduleUrl": "git+https://github.com/i18next/i18next.git",
"moduleVersion": "25.5.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/i18next.git"
},
{
"moduleName": "i18next-browser-languagedetector",
"moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git",
"moduleVersion": "8.2.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git"
},
{
"moduleName": "i18next-http-backend",
"moduleUrl": "git+ssh://[email protected]/i18next/i18next-http-backend.git",
"moduleVersion": "3.0.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+ssh://[email protected]/i18next/i18next-http-backend.git"
},
{
"moduleName": "jszip",
"moduleUrl": "git+https://github.com/Stuk/jszip.git",
"moduleVersion": "3.10.1",
"moduleLicense": "(MIT OR GPL-3.0-or-later)",
"moduleLicenseUrl": "git+https://github.com/Stuk/jszip.git"
},
{
"moduleName": "license-report",
"moduleUrl": "git+https://github.com/kessler/license-report.git",
"moduleVersion": "6.8.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/kessler/license-report.git"
},
{
"moduleName": "pdf-lib",
"moduleUrl": "git+https://github.com/Hopding/pdf-lib.git",
"moduleVersion": "1.17.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/Hopding/pdf-lib.git"
},
{
"moduleName": "pdfjs-dist",
"moduleUrl": "git+https://github.com/mozilla/pdf.js.git",
"moduleVersion": "5.4.149",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git"
},
{
"moduleName": "posthog-js",
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
"moduleVersion": "1.268.0",
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
},
{
"moduleName": "react",
"moduleUrl": "git+https://github.com/facebook/react.git",
"moduleVersion": "19.1.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
},
{
"moduleName": "react-dom",
"moduleUrl": "git+https://github.com/facebook/react.git",
"moduleVersion": "19.1.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
},
{
"moduleName": "react-i18next",
"moduleUrl": "git+https://github.com/i18next/react-i18next.git",
"moduleVersion": "15.7.3",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git"
},
{
"moduleName": "react-router-dom",
"moduleUrl": "git+https://github.com/remix-run/react-router.git",
"moduleVersion": "7.9.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git"
},
{
"moduleName": "tailwindcss",
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
"moduleVersion": "4.1.13",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
},
{
"moduleName": "web-vitals",
"moduleUrl": "git+https://github.com/GoogleChrome/web-vitals.git",
"moduleVersion": "5.1.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/GoogleChrome/web-vitals.git"
}
]
}
"dependencies": [
{
"moduleName": "@atlaskit/pragmatic-drag-and-drop",
"moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git",
"moduleVersion": "1.7.7",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
},
{
"moduleName": "@embedpdf/core",
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
},
{
"moduleName": "@embedpdf/engines",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-annotation",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-export",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-history",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-interaction-manager",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-loader",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-pan",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-render",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-rotate",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-scroll",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-search",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-selection",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-spread",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-thumbnail",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-tiling",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-viewport",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-zoom",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@emotion/react",
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
"moduleVersion": "11.14.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
},
{
"moduleName": "@emotion/styled",
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
"moduleVersion": "11.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
},
{
"moduleName": "@iconify/react",
"moduleUrl": "git+https://github.com/iconify/iconify.git",
"moduleVersion": "6.0.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/iconify/iconify.git"
},
{
"moduleName": "@mantine/core",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/dates",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/dropzone",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/hooks",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mui/icons-material",
"moduleUrl": "git+https://github.com/mui/material-ui.git",
"moduleVersion": "7.3.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
},
{
"moduleName": "@mui/material",
"moduleUrl": "git+https://github.com/mui/material-ui.git",
"moduleVersion": "7.3.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
},
{
"moduleName": "@tailwindcss/postcss",
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
"moduleVersion": "4.1.13",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
},
{
"moduleName": "@tanstack/react-virtual",
"moduleUrl": "git+https://github.com/TanStack/virtual.git",
"moduleVersion": "3.13.12",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git"
},
{
"moduleName": "autoprefixer",
"moduleUrl": "git+https://github.com/postcss/autoprefixer.git",
"moduleVersion": "10.4.21",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git"
},
{
"moduleName": "axios",
"moduleUrl": "git+https://github.com/axios/axios.git",
"moduleVersion": "1.12.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/axios/axios.git"
},
{
"moduleName": "i18next",
"moduleUrl": "git+https://github.com/i18next/i18next.git",
"moduleVersion": "25.5.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/i18next.git"
},
{
"moduleName": "i18next-browser-languagedetector",
"moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git",
"moduleVersion": "8.2.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git"
},
{
"moduleName": "i18next-http-backend",
"moduleUrl": "git+ssh://[email protected]/i18next/i18next-http-backend.git",
"moduleVersion": "3.0.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+ssh://[email protected]/i18next/i18next-http-backend.git"
},
{
"moduleName": "jszip",
"moduleUrl": "git+https://github.com/Stuk/jszip.git",
"moduleVersion": "3.10.1",
"moduleLicense": "(MIT OR GPL-3.0-or-later)",
"moduleLicenseUrl": "git+https://github.com/Stuk/jszip.git"
},
{
"moduleName": "license-report",
"moduleUrl": "git+https://github.com/kessler/license-report.git",
"moduleVersion": "6.8.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/kessler/license-report.git"
},
{
"moduleName": "pdf-lib",
"moduleUrl": "git+https://github.com/Hopding/pdf-lib.git",
"moduleVersion": "1.17.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/Hopding/pdf-lib.git"
},
{
"moduleName": "pdfjs-dist",
"moduleUrl": "git+https://github.com/mozilla/pdf.js.git",
"moduleVersion": "5.4.149",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git"
},
{
"moduleName": "posthog-js",
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
"moduleVersion": "1.268.0",
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
},
{
"moduleName": "react",
"moduleUrl": "git+https://github.com/facebook/react.git",
"moduleVersion": "19.1.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
},
{
"moduleName": "react-dom",
"moduleUrl": "git+https://github.com/facebook/react.git",
"moduleVersion": "19.1.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
},
{
"moduleName": "react-i18next",
"moduleUrl": "git+https://github.com/i18next/react-i18next.git",
"moduleVersion": "15.7.3",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git"
},
{
"moduleName": "react-router-dom",
"moduleUrl": "git+https://github.com/remix-run/react-router.git",
"moduleVersion": "7.9.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git"
},
{
"moduleName": "tailwindcss",
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
"moduleVersion": "4.1.13",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
},
{
"moduleName": "web-vitals",
"moduleUrl": "git+https://github.com/GoogleChrome/web-vitals.git",
"moduleVersion": "5.1.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/GoogleChrome/web-vitals.git"
}
]
}
+1 -3
View File
@@ -21,9 +21,7 @@ import "@app/utils/fileIdSafety";
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
{children}
</RainbowThemeProvider>
<RainbowThemeProvider>{children}</RainbowThemeProvider>
</PreferencesProvider>
);
}
+5 -7
View File
@@ -1,6 +1,6 @@
import { ReactNode } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { ReactNode } from "react";
import { useBanner } from "@app/contexts/BannerContext";
import NavigationWarningModal from "@app/components/shared/NavigationWarningModal";
interface AppLayoutProps {
children: ReactNode;
@@ -21,11 +21,9 @@ export function AppLayout({ children }: AppLayoutProps) {
height: 100% !important;
}
`}</style>
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
{banner}
<div style={{ flex: 1, minHeight: 0, height: 0 }}>
{children}
</div>
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
</div>
<NavigationWarningModal />
</>
+44 -44
View File
@@ -8,7 +8,12 @@ import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { PreferencesProvider, usePreferences } from "@app/contexts/PreferencesContext";
import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions, useAppConfig } from "@app/contexts/AppConfigContext";
import {
AppConfigProvider,
AppConfigProviderProps,
AppConfigRetryOptions,
useAppConfig,
} from "@app/contexts/AppConfigContext";
import { RightRailProvider } from "@app/contexts/RightRailContext";
import { ViewerProvider } from "@app/contexts/ViewerContext";
import { SignatureProvider } from "@app/contexts/SignatureContext";
@@ -20,8 +25,8 @@ import { BannerProvider } from "@app/contexts/BannerContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
import { useLogoAssets } from "@app/hooks/useLogoAssets";
import AppConfigLoader from "@app/components/shared/AppConfigLoader";
import { RedactionProvider } from "@app/contexts/RedactionContext";
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
@@ -41,14 +46,14 @@ function BrandingAssetManager() {
const { favicon, logo192, manifestHref } = useLogoAssets();
useEffect(() => {
if (typeof document === 'undefined') {
if (typeof document === "undefined") {
return;
}
const setLinkHref = (selector: string, href: string) => {
const link = document.querySelector<HTMLLinkElement>(selector);
if (link && link.getAttribute('href') !== href) {
link.setAttribute('href', href);
if (link && link.getAttribute("href") !== href) {
link.setAttribute("href", href);
}
};
@@ -62,7 +67,7 @@ function BrandingAssetManager() {
}
// Avoid requirement to have props which are required in app providers anyway
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, 'children' | 'retryOptions'>;
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, "children" | "retryOptions">;
export interface AppProvidersProps {
children: ReactNode;
@@ -98,49 +103,44 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<RainbowThemeProvider>
<ErrorBoundary>
<BannerProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<AppConfigLoader />
<ServerDefaultsSync />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RedactionProvider>
<FormFillProvider>
<AppConfigProvider retryOptions={appConfigRetryOptions} {...appConfigProviderProps}>
<ScarfTrackingInitializer />
<AppConfigLoader />
<ServerDefaultsSync />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RedactionProvider>
<FormFillProvider>
<AnnotationProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
<AdminTourOrchestrationProvider>{children}</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</AnnotationProvider>
</FormFillProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</FormFillProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</BannerProvider>
</ErrorBoundary>
</RainbowThemeProvider>
+88 -74
View File
@@ -1,19 +1,19 @@
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { Modal } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { StirlingFileStub } from '@app/types/fileContext';
import { useFileManager } from '@app/hooks/useFileManager';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { Tool } from '@app/types/tool';
import MobileLayout from '@app/components/fileManager/MobileLayout';
import DesktopLayout from '@app/components/fileManager/DesktopLayout';
import DragOverlay from '@app/components/fileManager/DragOverlay';
import { FileManagerProvider } from '@app/contexts/FileManagerContext';
import { Z_INDEX_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { isGoogleDriveConfigured, extractGoogleDriveBackendConfig } from '@app/services/googleDrivePickerService';
import { loadScript } from '@app/utils/scriptLoader';
import { useAllFiles } from '@app/contexts/FileContext';
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Modal } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { StirlingFileStub } from "@app/types/fileContext";
import { useFileManager } from "@app/hooks/useFileManager";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { Tool } from "@app/types/tool";
import MobileLayout from "@app/components/fileManager/MobileLayout";
import DesktopLayout from "@app/components/fileManager/DesktopLayout";
import DragOverlay from "@app/components/fileManager/DragOverlay";
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
import { Z_INDEX_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import { isGoogleDriveConfigured, extractGoogleDriveBackendConfig } from "@app/services/googleDrivePickerService";
import { loadScript } from "@app/utils/scriptLoader";
import { useAllFiles } from "@app/contexts/FileContext";
interface FileManagerProps {
selectedTool?: Tool | null;
@@ -32,47 +32,59 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { fileIds: activeFileIds } = useAllFiles();
// File management handlers
const isFileSupported = useCallback((fileName: string) => {
if (!selectedTool?.supportedFormats) return true;
const extension = fileName.split('.').pop()?.toLowerCase();
return selectedTool.supportedFormats.includes(extension || '');
}, [selectedTool?.supportedFormats]);
const isFileSupported = useCallback(
(fileName: string) => {
if (!selectedTool?.supportedFormats) return true;
const extension = fileName.split(".").pop()?.toLowerCase();
return selectedTool.supportedFormats.includes(extension || "");
},
[selectedTool?.supportedFormats],
);
const refreshRecentFiles = useCallback(async () => {
const files = await loadRecentFiles();
setRecentFiles(files);
}, [loadRecentFiles]);
const handleRecentFilesSelected = useCallback(async (files: StirlingFileStub[]) => {
try {
// Use StirlingFileStubs directly - preserves all metadata!
onRecentFileSelect(files);
} catch (error) {
console.error('Failed to process selected files:', error);
}
}, [onRecentFileSelect]);
const handleNewFileUpload = useCallback(async (files: File[]) => {
if (files.length > 0) {
const handleRecentFilesSelected = useCallback(
async (files: StirlingFileStub[]) => {
try {
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
onFileUpload(files);
await refreshRecentFiles();
// Use StirlingFileStubs directly - preserves all metadata!
onRecentFileSelect(files);
} catch (error) {
console.error('Failed to process dropped files:', error);
console.error("Failed to process selected files:", error);
}
}
}, [onFileUpload, refreshRecentFiles]);
},
[onRecentFileSelect],
);
const handleRemoveFileByIndex = useCallback(async (index: number) => {
await handleRemoveFile(index, recentFiles, setRecentFiles);
}, [handleRemoveFile, recentFiles]);
const handleNewFileUpload = useCallback(
async (files: File[]) => {
if (files.length > 0) {
try {
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
onFileUpload(files);
await refreshRecentFiles();
} catch (error) {
console.error("Failed to process dropped files:", error);
}
}
},
[onFileUpload, refreshRecentFiles],
);
const handleRemoveFileByIndex = useCallback(
async (index: number) => {
await handleRemoveFile(index, recentFiles, setRecentFiles);
},
[handleRemoveFile, recentFiles],
);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
useEffect(() => {
@@ -89,7 +101,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
return () => {
// StoredFileMetadata doesn't have blob URLs, so no cleanup needed
// Blob URLs are managed by FileContext and tool operations
console.log('FileManager unmounting - FileContext handles blob URL cleanup');
console.log("FileManager unmounting - FileContext handles blob URL cleanup");
};
}, []);
@@ -97,7 +109,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
// Use useMemo to only track Google Drive config changes, not all config updates
const googleDriveBackendConfig = useMemo(
() => extractGoogleDriveBackendConfig(config),
[config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId]
[config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId],
);
useEffect(() => {
@@ -105,29 +117,29 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
// Load scripts in parallel without blocking
Promise.all([
loadScript({
src: 'https://apis.google.com/js/api.js',
id: 'gapi-script',
src: "https://apis.google.com/js/api.js",
id: "gapi-script",
async: true,
defer: true,
}),
loadScript({
src: 'https://accounts.google.com/gsi/client',
id: 'gis-script',
src: "https://accounts.google.com/gsi/client",
id: "gis-script",
async: true,
defer: true,
}),
]).catch((error) => {
console.warn('Failed to preload Google Drive scripts:', error);
console.warn("Failed to preload Google Drive scripts:", error);
});
}
}, [googleDriveBackendConfig]);
// Modal size constants for consistent scaling
const modalHeight = '80vh';
const modalWidth = isMobile ? '100%' : '80vw';
const modalMaxWidth = isMobile ? '100%' : '1200px';
const modalMaxHeight = '1200px';
const modalMinWidth = isMobile ? '320px' : '800px';
const modalHeight = "80vh";
const modalWidth = isMobile ? "100%" : "80vw";
const modalMaxWidth = isMobile ? "100%" : "1200px";
const modalMaxHeight = "1200px";
const modalMinWidth = isMobile ? "320px" : "800px";
return (
<Modal
@@ -141,23 +153,25 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
zIndex={Z_INDEX_FILE_MANAGER_MODAL}
styles={{
content: {
position: 'relative',
margin: isMobile ? '1rem' : '2rem'
position: "relative",
margin: isMobile ? "1rem" : "2rem",
},
body: { padding: 0 },
header: { display: 'none' }
header: { display: "none" },
}}
>
<div style={{
position: 'relative',
height: modalHeight,
width: modalWidth,
maxWidth: modalMaxWidth,
maxHeight: modalMaxHeight,
minWidth: modalMinWidth,
margin: '0 auto',
overflow: 'hidden'
}}>
<div
style={{
position: "relative",
height: modalHeight,
width: modalWidth,
maxWidth: modalMaxWidth,
maxHeight: modalMaxHeight,
minWidth: modalMinWidth,
margin: "0 auto",
overflow: "hidden",
}}
>
<Dropzone
onDrop={handleNewFileUpload}
onDragEnter={() => setIsDragging(true)}
@@ -165,14 +179,14 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
multiple={true}
activateOnClick={false}
style={{
height: '100%',
width: '100%',
border: 'none',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--bg-file-manager)'
height: "100%",
width: "100%",
border: "none",
borderRadius: "var(--radius-md)",
backgroundColor: "var(--bg-file-manager)",
}}
styles={{
inner: { pointerEvents: 'all' }
inner: { pointerEvents: "all" },
}}
>
<FileManagerProvider
@@ -14,12 +14,7 @@ interface StorageStatsCardProps {
onReloadFiles: () => void;
}
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
storageStats,
filesCount,
onClearAll,
onReloadFiles,
}) => {
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, filesCount, onClearAll, onReloadFiles }) => {
const { t } = useTranslation();
if (!storageStats) return null;
@@ -59,12 +54,7 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
{t("fileManager.clearAll", "Clear All")}
</Button>
)}
<Button
variant="light"
color="blue"
size="xs"
onClick={onReloadFiles}
>
<Button variant="light" color="blue" size="xs" onClick={onReloadFiles}>
Reload Files
</Button>
</Group>
@@ -73,4 +63,4 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
);
};
export default StorageStatsCard;
export default StorageStatsCard;
@@ -1,4 +1,4 @@
import React, { createContext, useContext, ReactNode } from 'react';
import React, { createContext, useContext, ReactNode } from "react";
interface PDFAnnotationContextValue {
// Drawing mode management
@@ -58,7 +58,7 @@ export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
getImageData,
isPlacementMode,
signatureConfig,
setSignatureConfig
setSignatureConfig,
}) => {
const contextValue: PDFAnnotationContextValue = {
activateDrawMode,
@@ -72,20 +72,16 @@ export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
getImageData,
isPlacementMode,
signatureConfig,
setSignatureConfig
setSignatureConfig,
};
return (
<PDFAnnotationContext.Provider value={contextValue}>
{children}
</PDFAnnotationContext.Provider>
);
return <PDFAnnotationContext.Provider value={contextValue}>{children}</PDFAnnotationContext.Provider>;
};
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
const context = useContext(PDFAnnotationContext);
if (context === undefined) {
throw new Error('usePDFAnnotation must be used within a PDFAnnotationProvider');
throw new Error("usePDFAnnotation must be used within a PDFAnnotationProvider");
}
return context;
};
};
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react';
import { Stack, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { DrawingControls } from '@app/components/annotation/shared/DrawingControls';
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider';
import { useSignature } from '@app/contexts/SignatureContext';
import React, { useEffect, useState } from "react";
import { Stack, Alert, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { DrawingControls } from "@app/components/annotation/shared/DrawingControls";
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
import { usePDFAnnotation } from "@app/components/annotation/providers/PDFAnnotationProvider";
import { useSignature } from "@app/contexts/SignatureContext";
export interface AnnotationToolConfig {
enableDrawing?: boolean;
@@ -25,17 +25,13 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
config,
children,
onSignatureDataChange,
disabled = false
disabled = false,
}) => {
const { t } = useTranslation();
const {
activateSignaturePlacementMode,
undo,
redo
} = usePDFAnnotation();
const { activateSignaturePlacementMode, undo, redo } = usePDFAnnotation();
const { historyApiRef } = useSignature();
const [selectedColor, setSelectedColor] = useState('#000000');
const [selectedColor, setSelectedColor] = useState("#000000");
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [signatureData, setSignatureData] = useState<string | null>(null);
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
@@ -94,14 +90,12 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
signatureData,
onSignatureDataChange: handleSignatureDataChange,
onColorSwatchClick: () => setIsColorPickerOpen(true),
disabled
disabled,
})}
{/* Instructions for placing signature */}
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
<Text size="sm">
Click anywhere on the PDF to place your annotation.
</Text>
<Alert color="blue" title={t("sign.instructions.title", "How to add signature")}>
<Text size="sm">Click anywhere on the PDF to place your annotation.</Text>
</Alert>
{/* Color Picker Modal */}
@@ -1,15 +1,15 @@
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from '@mantine/core';
import { useState, useCallback, useEffect } from 'react';
import ColorizeIcon from '@mui/icons-material/Colorize';
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from "@mantine/core";
import { useState, useCallback, useEffect } from "react";
import ColorizeIcon from "@mui/icons-material/Colorize";
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
// the button is hidden in the UI if the API is not supported.
const supportsEyeDropper = typeof window !== 'undefined' && 'EyeDropper' in window;
const supportsEyeDropper = typeof window !== "undefined" && "EyeDropper" in window;
interface EyeDropper {
open(): Promise<{ sRGBHex: string }>;
}
declare const EyeDropper: { new(): EyeDropper };
declare const EyeDropper: { new (): EyeDropper };
interface ColorControlProps {
value: string;
@@ -24,7 +24,9 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
// Only propagate to the parent (which triggers expensive annotation updates)
// on onChangeEnd (mouse-up / swatch click), preventing infinite re-render loops.
const [localColor, setLocalColor] = useState(value);
useEffect(() => { setLocalColor(value); }, [value]);
useEffect(() => {
setLocalColor(value);
}, [value]);
const handleEyeDropper = useCallback(async () => {
if (!supportsEyeDropper) return;
@@ -50,13 +52,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
@@ -73,8 +75,16 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
onChange={setLocalColor}
onChangeEnd={onChange}
swatches={[
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff',
'#ffff00', '#ff00ff', '#00ffff', '#ffa500', 'transparent'
"#000000",
"#ffffff",
"#ff0000",
"#00ff00",
"#0000ff",
"#ffff00",
"#ff00ff",
"#00ffff",
"#ffa500",
"transparent",
]}
swatchesPerRow={5}
size="sm"
@@ -82,7 +92,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
{supportsEyeDropper && (
<Group justify="flex-end">
<Tooltip label="Pick colour from screen">
<ActionIcon variant="subtle" color="gray" size="sm" onClick={handleEyeDropper} style={{ color: 'var(--text-primary)' }}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={handleEyeDropper}
style={{ color: "var(--text-primary)" }}
>
<ColorizeIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
@@ -1,6 +1,6 @@
import React from 'react';
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import React from "react";
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface ColorPickerProps {
isOpen: boolean;
@@ -26,48 +26,42 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
opacityLabel,
}) => {
const { t } = useTranslation();
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
const resolvedOpacityLabel = opacityLabel ?? t('annotation.opacity', 'Opacity');
const resolvedTitle = title ?? t("colorPicker.title", "Choose colour");
const resolvedOpacityLabel = opacityLabel ?? t("annotation.opacity", "Opacity");
return (
<Modal
opened={isOpen}
onClose={onClose}
title={resolvedTitle}
size="sm"
centered
>
<Modal opened={isOpen} onClose={onClose} title={resolvedTitle} size="sm" centered>
<Stack gap="md">
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={onColorChange}
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
swatches={["#000000", "#0066cc", "#cc0000", "#cc6600", "#009900", "#6600cc"]}
swatchesPerRow={6}
size="lg"
fullWidth
/>
{showOpacity && onOpacityChange && opacity !== undefined && (
<Stack gap="xs">
<Text size="sm" fw={500}>{resolvedOpacityLabel}</Text>
<Text size="sm" fw={500}>
{resolvedOpacityLabel}
</Text>
<Slider
min={10}
max={100}
value={opacity}
onChange={onOpacityChange}
marks={[
{ value: 25, label: '25%' },
{ value: 50, label: '50%' },
{ value: 75, label: '75%' },
{ value: 100, label: '100%' },
{ value: 25, label: "25%" },
{ value: 50, label: "50%" },
{ value: 75, label: "75%" },
{ value: 100, label: "100%" },
]}
/>
</Stack>
)}
<Group justify="flex-end">
<Button onClick={onClose}>
{t('common.done', 'Done')}
</Button>
<Button onClick={onClose}>{t("common.done", "Done")}</Button>
</Group>
</Stack>
</Modal>
@@ -80,18 +74,6 @@ interface ColorSwatchButtonProps {
size?: number;
}
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
color,
onClick,
size = 24
}) => {
return (
<ColorSwatch
color={color}
size={size}
radius={0}
style={{ cursor: 'pointer' }}
onClick={onClick}
/>
);
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({ color, onClick, size = 24 }) => {
return <ColorSwatch color={color} size={size} radius={0} style={{ cursor: "pointer" }} onClick={onClick} />;
};
@@ -1,10 +1,10 @@
import React, { useEffect, useRef, useState } from 'react';
import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
import SignaturePad from 'signature_pad';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import React, { useEffect, useRef, useState } from "react";
import { Paper, Button, Modal, Stack, Text, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ColorSwatchButton } from "@app/components/annotation/shared/ColorPicker";
import PenSizeSelector from "@app/components/tools/sign/PenSizeSelector";
import SignaturePad from "signature_pad";
import { PrivateContent } from "@app/components/shared/PrivateContent";
interface DrawingCanvasProps {
selectedColor: string;
@@ -68,7 +68,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
if (savedSignatureData) {
const img = new Image();
img.onload = () => {
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
}
@@ -92,13 +92,16 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
}, [autoOpen]);
const trimCanvas = (canvas: HTMLCanvasElement): string => {
const ctx = canvas.getContext('2d');
if (!ctx) return canvas.toDataURL('image/png');
const ctx = canvas.getContext("2d");
if (!ctx) return canvas.toDataURL("image/png");
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
let minX = canvas.width,
minY = canvas.height,
maxX = 0,
maxY = 0;
// Find bounds of non-transparent pixels
for (let y = 0; y < canvas.height; y++) {
@@ -117,21 +120,21 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
const trimHeight = maxY - minY + 1;
// Create trimmed canvas
const trimmedCanvas = document.createElement('canvas');
const trimmedCanvas = document.createElement("canvas");
trimmedCanvas.width = trimWidth;
trimmedCanvas.height = trimHeight;
const trimmedCtx = trimmedCanvas.getContext('2d');
const trimmedCtx = trimmedCanvas.getContext("2d");
if (trimmedCtx) {
trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
}
return trimmedCanvas.toDataURL('image/png');
return trimmedCanvas.toDataURL("image/png");
};
const renderPreview = (dataUrl: string) => {
const canvas = previewCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
const img = new Image();
@@ -153,7 +156,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
const canvas = modalCanvasRef.current;
if (canvas) {
const trimmedPng = trimCanvas(canvas);
const untrimmedPng = canvas.toDataURL('image/png');
const untrimmedPng = canvas.toDataURL("image/png");
setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration
onSignatureDataChange(trimmedPng);
renderPreview(trimmedPng);
@@ -176,7 +179,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
padRef.current.clear();
}
if (previewCanvasRef.current) {
const ctx = previewCanvasRef.current.getContext('2d');
const ctx = previewCanvasRef.current.getContext("2d");
if (ctx) {
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
}
@@ -209,7 +212,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
useEffect(() => {
const canvas = previewCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
if (!initialSignatureData) {
@@ -227,42 +230,45 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
<Paper withBorder p="md">
<Stack gap="sm">
<PrivateContent>
<Text fw={500}>{t('sign.canvas.heading', 'Draw your signature')}</Text>
<canvas
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'pointer',
backgroundColor: '#ffffff',
width: '100%',
}}
onClick={disabled ? undefined : openModal}
/>
<Text fw={500}>{t("sign.canvas.heading", "Draw your signature")}</Text>
<canvas
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: "1px solid #ccc",
borderRadius: "4px",
cursor: disabled ? "default" : "pointer",
backgroundColor: "#ffffff",
width: "100%",
}}
onClick={disabled ? undefined : openModal}
/>
</PrivateContent>
<Text size="sm" c="dimmed" ta="center">
{t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')}
{t("sign.canvas.clickToOpen", "Click to open the drawing canvas")}
</Text>
</Stack>
</Paper>
<Modal opened={modalOpen} onClose={closeModal} title={t('sign.canvas.modalTitle', 'Draw your signature')} size="auto" centered>
<Modal
opened={modalOpen}
onClose={closeModal}
title={t("sign.canvas.modalTitle", "Draw your signature")}
size="auto"
centered
>
<Stack gap="md">
<Group gap="lg" align="flex-end" wrap="wrap">
<Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}>
{t('sign.canvas.colorLabel', 'Colour')}
{t("sign.canvas.colorLabel", "Colour")}
</Text>
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
<ColorSwatchButton color={selectedColor} onClick={onColorSwatchClick} />
</Stack>
<Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}>
{t('sign.canvas.penSizeLabel', 'Pen size')}
{t("sign.canvas.penSizeLabel", "Pen size")}
</Text>
<PenSizeSelector
value={penSize}
@@ -272,9 +278,9 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
updatePenSize(size);
}}
onInputChange={onPenSizeInputChange}
placeholder={t('sign.canvas.penSizePlaceholder', 'Size')}
placeholder={t("sign.canvas.penSizePlaceholder", "Size")}
size="compact-sm"
style={{ width: '80px' }}
style={{ width: "80px" }}
/>
</Stack>
</Group>
@@ -286,26 +292,24 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
if (el) initPad(el);
}}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
display: 'block',
touchAction: 'none',
backgroundColor: 'white',
width: '100%',
maxWidth: '50rem',
height: '25rem',
cursor: 'crosshair',
border: "1px solid #ccc",
borderRadius: "4px",
display: "block",
touchAction: "none",
backgroundColor: "white",
width: "100%",
maxWidth: "50rem",
height: "25rem",
cursor: "crosshair",
}}
/>
</PrivateContent>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<Button variant="subtle" color="red" onClick={clear}>
{t('sign.canvas.clear', 'Clear canvas')}
</Button>
<Button onClick={closeModal}>
{t('common.done', 'Done')}
{t("sign.canvas.clear", "Clear canvas")}
</Button>
<Button onClick={closeModal}>{t("common.done", "Done")}</Button>
</div>
</Stack>
</Modal>
@@ -1,7 +1,7 @@
import React from 'react';
import { Group, Button, ActionIcon, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { LocalIcon } from '@app/components/shared/LocalIcon';
import React from "react";
import { Group, Button, ActionIcon, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { LocalIcon } from "@app/components/shared/LocalIcon";
interface DrawingControlsProps {
onUndo?: () => void;
@@ -35,30 +35,30 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
return (
<Group gap="xs" wrap="nowrap" align="center">
{onUndo && (
<Tooltip label={t('sign.undo', 'Undo')}>
<Tooltip label={t("sign.undo", "Undo")}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t('sign.undo', 'Undo')}
aria-label={t("sign.undo", "Undo")}
onClick={onUndo}
disabled={undoDisabled}
color={undoDisabled ? 'gray' : 'blue'}
color={undoDisabled ? "gray" : "blue"}
>
<LocalIcon icon="undo" width={20} height={20} style={{ color: 'currentColor' }} />
<LocalIcon icon="undo" width={20} height={20} style={{ color: "currentColor" }} />
</ActionIcon>
</Tooltip>
)}
{onRedo && (
<Tooltip label={t('sign.redo', 'Redo')}>
<Tooltip label={t("sign.redo", "Redo")}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t('sign.redo', 'Redo')}
aria-label={t("sign.redo", "Redo")}
onClick={onRedo}
disabled={redoDisabled}
color={redoDisabled ? 'gray' : 'blue'}
color={redoDisabled ? "gray" : "blue"}
>
<LocalIcon icon="redo" width={20} height={20} style={{ color: 'currentColor' }} />
<LocalIcon icon="redo" width={20} height={20} style={{ color: "currentColor" }} />
</ActionIcon>
</Tooltip>
)}
@@ -67,13 +67,7 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
{/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && (
<Button
variant="filled"
color="blue"
onClick={onPlaceSignature}
disabled={disabled || !hasSignatureData}
ml="auto"
>
<Button variant="filled" color="blue" onClick={onPlaceSignature} disabled={disabled || !hasSignatureData} ml="auto">
{placeButtonText}
</Button>
)}
@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { FileInput, Text, Stack, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { removeWhiteBackground } from '@app/utils/imageTransparency';
import { alert } from '@app/components/toast';
import React, { useState } from "react";
import { FileInput, Text, Stack, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { removeWhiteBackground } from "@app/utils/imageTransparency";
import { alert } from "@app/components/toast";
interface ImageUploaderProps {
onImageChange: (file: File | null) => void;
@@ -22,7 +22,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
placeholder,
hint,
allowBackgroundRemoval = false,
onProcessedImageData
onProcessedImageData,
}) => {
const { t } = useTranslation();
const [removeBackground, setRemoveBackground] = useState(false);
@@ -36,15 +36,18 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
try {
const transparentImageDataUrl = await removeWhiteBackground(imageSource, {
autoDetectCorner: true,
tolerance: 15
tolerance: 15,
});
onProcessedImageData?.(transparentImageDataUrl);
} catch (error) {
console.error('Error removing background:', error);
console.error("Error removing background:", error);
alert({
title: t('sign.image.backgroundRemovalFailedTitle', 'Background removal failed'),
body: t('sign.image.backgroundRemovalFailedMessage', 'Could not remove the background from the image. Using original image instead.'),
alertType: 'error'
title: t("sign.image.backgroundRemovalFailedTitle", "Background removal failed"),
body: t(
"sign.image.backgroundRemovalFailedMessage",
"Could not remove the background from the image. Using original image instead.",
),
alertType: "error",
});
onProcessedImageData?.(null);
} finally {
@@ -52,7 +55,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
}
} else {
// When background removal is disabled, return the original image data
if (typeof imageSource === 'string') {
if (typeof imageSource === "string") {
onProcessedImageData?.(imageSource);
} else {
// Convert File to data URL if needed
@@ -69,8 +72,8 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
if (file && !disabled) {
try {
// Validate that it's actually an image file or SVG
if (!file.type.startsWith('image/') && !file.name.toLowerCase().endsWith('.svg')) {
console.error('Selected file is not an image or SVG');
if (!file.type.startsWith("image/") && !file.name.toLowerCase().endsWith(".svg")) {
console.error("Selected file is not an image or SVG");
return;
}
@@ -78,10 +81,10 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
onImageChange(file);
let dataUrlToProcess: string;
// Check if file is SVG
const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg');
const isSvg = file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
if (isSvg) {
// For SVG, convert to PNG so it can be embedded in PDF
dataUrlToProcess = await convertSvgToPng(file);
@@ -98,7 +101,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
setOriginalImageData(dataUrlToProcess);
await processImage(dataUrlToProcess, removeBackground);
} catch (error) {
console.error('Error processing image file:', error);
console.error("Error processing image file:", error);
}
} else if (!file) {
// Clear image data when no file is selected
@@ -116,33 +119,33 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
reader.onload = async (e) => {
try {
const svgText = e.target?.result as string;
// Parse SVG to get dimensions
const parser = new DOMParser();
const svgDoc = parser.parseFromString(svgText, 'image/svg+xml');
const svgDoc = parser.parseFromString(svgText, "image/svg+xml");
const svgElement = svgDoc.documentElement;
// Get SVG dimensions
let width = 800; // Default width
let width = 800; // Default width
let height = 600; // Default height
if (svgElement.hasAttribute('width') && svgElement.hasAttribute('height')) {
width = parseFloat(svgElement.getAttribute('width') || '800');
height = parseFloat(svgElement.getAttribute('height') || '600');
} else if (svgElement.hasAttribute('viewBox')) {
const viewBox = svgElement.getAttribute('viewBox')?.split(/\s+|,/);
if (svgElement.hasAttribute("width") && svgElement.hasAttribute("height")) {
width = parseFloat(svgElement.getAttribute("width") || "800");
height = parseFloat(svgElement.getAttribute("height") || "600");
} else if (svgElement.hasAttribute("viewBox")) {
const viewBox = svgElement.getAttribute("viewBox")?.split(/\s+|,/);
if (viewBox && viewBox.length === 4) {
width = parseFloat(viewBox[2]);
height = parseFloat(viewBox[3]);
}
}
// Ensure reasonable dimensions
if (width === 0 || height === 0 || !isFinite(width) || !isFinite(height)) {
width = 800;
height = 600;
}
// Scale large SVGs down
const maxDimension = 2048;
if (width > maxDimension || height > maxDimension) {
@@ -150,68 +153,73 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
width *= scale;
height *= scale;
}
console.log('Converting SVG to PNG:', { width, height });
console.log("Converting SVG to PNG:", { width, height });
// Create an image element to render SVG
const img = new Image();
const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
const blob = new Blob([svgText], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
img.onload = () => {
try {
// Use computed dimensions or image natural dimensions
const finalWidth = img.naturalWidth || img.width || width;
const finalHeight = img.naturalHeight || img.height || height;
console.log('Image loaded:', { naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, finalWidth, finalHeight });
console.log("Image loaded:", {
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
finalWidth,
finalHeight,
});
// Create canvas to convert to PNG
const canvas = document.createElement('canvas');
const canvas = document.createElement("canvas");
canvas.width = finalWidth;
canvas.height = finalHeight;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error('Failed to get canvas context'));
reject(new Error("Failed to get canvas context"));
return;
}
// Fill with white background (optional, for transparency support)
ctx.fillStyle = 'white';
ctx.fillStyle = "white";
ctx.fillRect(0, 0, finalWidth, finalHeight);
// Draw SVG
ctx.drawImage(img, 0, 0, finalWidth, finalHeight);
URL.revokeObjectURL(url);
// Convert canvas to PNG data URL
const pngDataUrl = canvas.toDataURL('image/png');
console.log('SVG converted to PNG successfully');
const pngDataUrl = canvas.toDataURL("image/png");
console.log("SVG converted to PNG successfully");
resolve(pngDataUrl);
} catch (error) {
URL.revokeObjectURL(url);
console.error('Error during canvas rendering:', error);
console.error("Error during canvas rendering:", error);
reject(error);
}
};
img.onerror = (error) => {
URL.revokeObjectURL(url);
console.error('Failed to load SVG image:', error);
reject(new Error('Failed to load SVG image'));
console.error("Failed to load SVG image:", error);
reject(new Error("Failed to load SVG image"));
};
img.src = url;
} catch (error) {
console.error('Error parsing SVG:', error);
console.error("Error parsing SVG:", error);
reject(error);
}
};
reader.onerror = () => {
console.error('Error reading file:', reader.error);
console.error("Error reading file:", reader.error);
reject(reader.error);
};
reader.readAsText(file);
@@ -231,7 +239,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
<PrivateContent>
<FileInput
label={label}
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
placeholder={placeholder || t("sign.image.placeholder", "Select image file")}
accept="image/*,.svg"
onChange={handleImageChange}
disabled={disabled || isProcessing}
@@ -239,7 +247,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
</PrivateContent>
{allowBackgroundRemoval && (
<Checkbox
label={t('sign.image.removeBackground', 'Remove white background (make transparent)')}
label={t("sign.image.removeBackground", "Remove white background (make transparent)")}
checked={removeBackground}
onChange={(event) => handleBackgroundRemovalChange(event.currentTarget.checked)}
disabled={disabled || !currentFile || isProcessing}
@@ -252,7 +260,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
)}
{isProcessing && (
<Text size="sm" c="dimmed">
{t('sign.image.processing', 'Processing image...')}
{t("sign.image.processing", "Processing image...")}
</Text>
)}
</Stack>
@@ -1,7 +1,7 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import OpacityIcon from '@mui/icons-material/Opacity';
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import OpacityIcon from "@mui/icons-material/Opacity";
interface OpacityControlProps {
value: number; // 0-100
@@ -16,7 +16,7 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
return (
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.opacity', 'Opacity')}>
<Tooltip label={t("annotation.opacity", "Opacity")}>
<ActionIcon
variant="subtle"
color="gray"
@@ -26,13 +26,13 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
@@ -44,15 +44,9 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
<Popover.Dropdown>
<Stack gap="xs" style={{ minWidth: 150 }}>
<Text size="xs" fw={500}>
{t('annotation.opacity', 'Opacity')}
{t("annotation.opacity", "Opacity")}
</Text>
<Slider
value={value}
onChange={onChange}
min={10}
max={100}
label={(val) => `${val}%`}
/>
<Slider value={value} onChange={onChange} min={10} max={100} label={(val) => `${val}%`} />
</Stack>
</Popover.Dropdown>
</Popover>
@@ -1,15 +1,15 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import type { TrackedAnnotation } from '@embedpdf/plugin-annotation';
import type { PdfAnnotationObject } from '@embedpdf/models';
import type { AnnotationPatch } from '@app/components/viewer/viewerTypes';
import TuneIcon from '@mui/icons-material/Tune';
import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight';
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import type { TrackedAnnotation } from "@embedpdf/plugin-annotation";
import type { PdfAnnotationObject } from "@embedpdf/models";
import type { AnnotationPatch } from "@app/components/viewer/viewerTypes";
import TuneIcon from "@mui/icons-material/Tune";
import FormatAlignLeftIcon from "@mui/icons-material/FormatAlignLeft";
import FormatAlignCenterIcon from "@mui/icons-material/FormatAlignCenter";
import FormatAlignRightIcon from "@mui/icons-material/FormatAlignRight";
export type PropertiesAnnotationType = 'text' | 'note' | 'shape';
export type PropertiesAnnotationType = "text" | "note" | "shape";
interface PropertiesPopoverProps {
annotationType: PropertiesAnnotationType;
@@ -18,12 +18,7 @@ interface PropertiesPopoverProps {
disabled?: boolean;
}
export function PropertiesPopover({
annotationType,
annotation,
onUpdate,
disabled = false,
}: PropertiesPopoverProps) {
export function PropertiesPopover({ annotationType, annotation, onUpdate, disabled = false }: PropertiesPopoverProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
@@ -41,17 +36,17 @@ export function PropertiesPopover({
const fontSize = obj?.fontSize ?? 14;
const textAlign = obj?.textAlign;
const currentAlign =
typeof textAlign === 'number'
typeof textAlign === "number"
? textAlign === 1
? 'center'
? "center"
: textAlign === 2
? 'right'
: 'left'
: textAlign === 'center'
? 'center'
: textAlign === 'right'
? 'right'
: 'left';
? "right"
: "left"
: textAlign === "center"
? "center"
: textAlign === "right"
? "right"
: "left";
// For shapes
const opacity = Math.round((obj?.opacity ?? 1) * 100);
@@ -63,7 +58,7 @@ export function PropertiesPopover({
{/* Font Size */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.fontSize', 'Font size')}
{t("annotation.fontSize", "Font size")}
</Text>
<Slider
value={fontSize}
@@ -77,7 +72,7 @@ export function PropertiesPopover({
{/* Opacity */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.opacity', 'Opacity')}
{t("annotation.opacity", "Opacity")}
</Text>
<Slider
value={Math.round((obj?.opacity ?? 1) * 100)}
@@ -91,25 +86,25 @@ export function PropertiesPopover({
{/* Text Alignment */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.textAlignment', 'Text Alignment')}
{t("annotation.textAlignment", "Text Alignment")}
</Text>
<Group gap="xs">
<ActionIcon
variant={currentAlign === 'left' ? 'filled' : 'default'}
variant={currentAlign === "left" ? "filled" : "default"}
onClick={() => onUpdate({ textAlign: 0 })}
size="md"
>
<FormatAlignLeftIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'center' ? 'filled' : 'default'}
variant={currentAlign === "center" ? "filled" : "default"}
onClick={() => onUpdate({ textAlign: 1 })}
size="md"
>
<FormatAlignCenterIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'right' ? 'filled' : 'default'}
variant={currentAlign === "right" ? "filled" : "default"}
onClick={() => onUpdate({ textAlign: 2 })}
size="md"
>
@@ -125,7 +120,7 @@ export function PropertiesPopover({
{/* Opacity */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.opacity', 'Opacity')}
{t("annotation.opacity", "Opacity")}
</Text>
<Slider
value={opacity}
@@ -148,7 +143,7 @@ export function PropertiesPopover({
<Group gap="xs" align="flex-end">
<div style={{ flex: 1 }}>
<Text size="xs" fw={500} mb={4}>
{t('annotation.strokeWidth', 'Stroke')}
{t("annotation.strokeWidth", "Stroke")}
</Text>
<Slider
value={strokeWidth}
@@ -166,7 +161,7 @@ export function PropertiesPopover({
</div>
<Button
size="xs"
variant={!borderVisible ? 'filled' : 'light'}
variant={!borderVisible ? "filled" : "light"}
onClick={() => {
const newValue = borderVisible ? 0 : 1;
onUpdate({
@@ -176,9 +171,7 @@ export function PropertiesPopover({
});
}}
>
{borderVisible
? t('annotation.borderOn', 'Border: On')
: t('annotation.borderOff', 'Border: Off')}
{borderVisible ? t("annotation.borderOn", "Border: On") : t("annotation.borderOff", "Border: Off")}
</Button>
</Group>
</div>
@@ -188,7 +181,7 @@ export function PropertiesPopover({
return (
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.properties', 'Properties')}>
<Tooltip label={t("annotation.properties", "Properties")}>
<ActionIcon
variant="subtle"
color="gray"
@@ -198,13 +191,13 @@ export function PropertiesPopover({
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
@@ -214,8 +207,8 @@ export function PropertiesPopover({
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
{(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()}
{annotationType === 'shape' && renderShapeControls()}
{(annotationType === "text" || annotationType === "note") && renderTextNoteControls()}
{annotationType === "shape" && renderShapeControls()}
</Popover.Dropdown>
</Popover>
);
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
import React, { useState, useEffect } from "react";
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
interface TextInputWithFontProps {
text: string;
@@ -12,8 +12,8 @@ interface TextInputWithFontProps {
onFontFamilyChange: (family: string) => void;
textColor?: string;
onTextColorChange?: (color: string) => void;
textAlign?: 'left' | 'center' | 'right';
onTextAlignChange?: (align: 'left' | 'center' | 'right') => void;
textAlign?: "left" | "center" | "right";
onTextAlignChange?: (align: "left" | "center" | "right") => void;
disabled?: boolean;
label: string;
placeholder: string;
@@ -31,9 +31,9 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
onFontSizeChange,
fontFamily,
onFontFamilyChange,
textColor = '#000000',
textColor = "#000000",
onTextColorChange,
textAlign = 'left',
textAlign = "left",
onTextAlignChange,
disabled = false,
label,
@@ -42,7 +42,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
fontSizeLabel,
fontSizePlaceholder,
colorLabel,
onAnyChange
onAnyChange,
}) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
@@ -61,14 +61,37 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
}, [textColor]);
const fontOptions = [
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Times-Roman', label: 'Times' },
{ value: 'Courier', label: 'Courier' },
{ value: 'Arial', label: 'Arial' },
{ value: 'Georgia', label: 'Georgia' },
{ value: "Helvetica", label: "Helvetica" },
{ value: "Times-Roman", label: "Times" },
{ value: "Courier", label: "Courier" },
{ value: "Arial", label: "Arial" },
{ value: "Georgia", label: "Georgia" },
];
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
const fontSizeOptions = [
"8",
"12",
"16",
"20",
"24",
"28",
"32",
"36",
"40",
"48",
"56",
"64",
"72",
"80",
"96",
"112",
"128",
"144",
"160",
"176",
"192",
"200",
];
// Validate hex color
const isValidHexColor = (color: string): boolean => {
@@ -94,7 +117,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
label={fontLabel}
value={fontFamily}
onChange={(value) => {
onFontFamilyChange(value || 'Helvetica');
onFontFamilyChange(value || "Helvetica");
onAnyChange?.();
}}
data={fontOptions}
@@ -187,7 +210,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
setColorInput(textColor);
}
}}
style={{ width: '100%' }}
style={{ width: "100%" }}
rightSection={
<Box
onClick={() => !disabled && setIsColorPickerOpen(true)}
@@ -195,9 +218,9 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
width: 24,
height: 24,
backgroundColor: textColor,
border: '1px solid #ccc',
border: "1px solid #ccc",
borderRadius: 4,
cursor: disabled ? 'default' : 'pointer'
cursor: disabled ? "default" : "pointer",
}}
/>
}
@@ -224,14 +247,14 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
<SegmentedControl
value={textAlign}
onChange={(value: string) => {
onTextAlignChange(value as 'left' | 'center' | 'right');
onTextAlignChange(value as "left" | "center" | "right");
onAnyChange?.();
}}
disabled={disabled}
data={[
{ label: t('textAlign.left', 'Left'), value: 'left' },
{ label: t('textAlign.center', 'Center'), value: 'center' },
{ label: t('textAlign.right', 'Right'), value: 'right' },
{ label: t("textAlign.left", "Left"), value: "left" },
{ label: t("textAlign.center", "Center"), value: "center" },
{ label: t("textAlign.right", "Right"), value: "right" },
]}
/>
)}
@@ -1,7 +1,7 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import LineWeightIcon from '@mui/icons-material/LineWeight';
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import LineWeightIcon from "@mui/icons-material/LineWeight";
interface WidthControlProps {
value: number;
@@ -18,7 +18,7 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
return (
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.width', 'Width')}>
<Tooltip label={t("annotation.width", "Width")}>
<ActionIcon
variant="subtle"
color="gray"
@@ -28,13 +28,13 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
@@ -46,15 +46,9 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
<Popover.Dropdown>
<Stack gap="xs" style={{ minWidth: 150 }}>
<Text size="xs" fw={500}>
{t('annotation.width', 'Width')}
{t("annotation.width", "Width")}
</Text>
<Slider
value={value}
onChange={onChange}
min={min}
max={max}
label={(val) => `${val}pt`}
/>
<Slider value={value} onChange={onChange} min={min} max={max} label={(val) => `${val}pt`} />
</Stack>
</Popover.Dropdown>
</Popover>
@@ -1,33 +1,26 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
import { DrawingCanvas } from '@app/components/annotation/shared/DrawingCanvas';
import React, { useState } from "react";
import { Stack } from "@mantine/core";
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
interface DrawingToolProps {
onDrawingChange?: (data: string | null) => void;
disabled?: boolean;
}
export const DrawingTool: React.FC<DrawingToolProps> = ({
onDrawingChange,
disabled = false
}) => {
const [selectedColor] = useState('#000000');
export const DrawingTool: React.FC<DrawingToolProps> = ({ onDrawingChange, disabled = false }) => {
const [selectedColor] = useState("#000000");
const [penSize, setPenSize] = useState(2);
const [penSizeInput, setPenSizeInput] = useState('2');
const [penSizeInput, setPenSizeInput] = useState("2");
const toolConfig = {
enableDrawing: true,
showPlaceButton: true,
placeButtonText: "Place Drawing"
placeButtonText: "Place Drawing",
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onDrawingChange}
disabled={disabled}
>
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onDrawingChange} disabled={disabled}>
<Stack gap="sm">
<DrawingCanvas
selectedColor={selectedColor}
@@ -42,4 +35,4 @@ export const DrawingTool: React.FC<DrawingToolProps> = ({
</Stack>
</BaseAnnotationTool>
);
};
};
@@ -1,17 +1,14 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
import { ImageUploader } from '@app/components/annotation/shared/ImageUploader';
import React, { useState } from "react";
import { Stack } from "@mantine/core";
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
interface ImageToolProps {
onImageChange?: (data: string | null) => void;
disabled?: boolean;
}
export const ImageTool: React.FC<ImageToolProps> = ({
onImageChange,
disabled = false
}) => {
export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled = false }) => {
const [, setImageData] = useState<string | null>(null);
const handleImageUpload = async (file: File | null) => {
@@ -23,7 +20,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error('Failed to read file'));
reject(new Error("Failed to read file"));
}
};
reader.onerror = () => reject(reader.error);
@@ -33,7 +30,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
setImageData(result);
onImageChange?.(result);
} catch (error) {
console.error('Error reading file:', error);
console.error("Error reading file:", error);
}
} else if (!file) {
setImageData(null);
@@ -44,15 +41,11 @@ export const ImageTool: React.FC<ImageToolProps> = ({
const toolConfig = {
enableImageUpload: true,
showPlaceButton: true,
placeButtonText: "Place Image"
placeButtonText: "Place Image",
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onImageChange}
disabled={disabled}
>
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onImageChange} disabled={disabled}>
<Stack gap="sm">
<ImageUploader
onImageChange={handleImageUpload}
@@ -64,4 +57,4 @@ export const ImageTool: React.FC<ImageToolProps> = ({
</Stack>
</BaseAnnotationTool>
);
};
};
@@ -1,14 +1,14 @@
import React, { useRef, useState } from 'react';
import { Button, Group, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import AddIcon from '@mui/icons-material/Add';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import styles from '@app/components/fileEditor/FileEditor.module.css';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
import React, { useRef, useState } from "react";
import { Button, Group, useMantineColorScheme } from "@mantine/core";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useLogoAssets } from "@app/hooks/useLogoAssets";
import styles from "@app/components/fileEditor/FileEditor.module.css";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
interface AddFileCardProps {
onFileSelect: (files: File[]) => void;
@@ -16,11 +16,7 @@ interface AddFileCardProps {
multiple?: boolean;
}
const AddFileCard = ({
onFileSelect,
accept,
multiple = true
}: AddFileCardProps) => {
const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const { openFilesModal } = useFilesModalContext();
@@ -38,7 +34,7 @@ const AddFileCard = ({
e.stopPropagation();
const files = await openFilesFromDisk({
multiple,
onFallbackOpen: () => fileInputRef.current?.click()
onFallbackOpen: () => fileInputRef.current?.click(),
});
if (files.length > 0) {
onFileSelect(files);
@@ -56,7 +52,7 @@ const AddFileCard = ({
onFileSelect(files);
}
// Reset input so same files can be selected again
event.target.value = '';
event.target.value = "";
};
return (
@@ -67,17 +63,17 @@ const AddFileCard = ({
accept={accept}
multiple={multiple}
onChange={handleFileChange}
style={{ display: 'none' }}
style={{ display: "none" }}
/>
<div
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
tabIndex={0}
role="button"
aria-label={t('fileEditor.addFiles', 'Add files')}
aria-label={t("fileEditor.addFiles", "Add files")}
onClick={handleCardClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleCardClick();
}
@@ -86,11 +82,9 @@ const AddFileCard = ({
{/* Header bar - matches FileEditorThumbnail structure */}
<div className={`${styles.header} ${styles.addFileHeader}`}>
<div className={styles.logoMark}>
<AddIcon sx={{ color: 'inherit', fontSize: '1.5rem' }} />
</div>
<div className={styles.headerIndex}>
{t('fileEditor.addFiles', 'Add Files')}
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
</div>
<div className={styles.headerIndex}>{t("fileEditor.addFiles", "Add Files")}</div>
<div className={styles.kebab} />
</div>
@@ -99,84 +93,81 @@ const AddFileCard = ({
{/* Stirling PDF Branding */}
<Group gap="xs" align="center">
<img
src={colorScheme === 'dark' ? wordmark.white : wordmark.grey}
src={colorScheme === "dark" ? wordmark.white : wordmark.grey}
alt="Stirling PDF"
style={{ height: '2.2rem', width: 'auto' }}
style={{ height: "2.2rem", width: "auto" }}
/>
</Group>
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.6rem',
width: '100%',
marginTop: '0.8rem',
marginBottom: '0.8rem'
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.6rem",
width: "100%",
marginTop: "0.8rem",
marginBottom: "0.8rem",
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)",
border: "1px solid var(--landing-button-border)",
borderRadius: "2rem",
height: "38px",
paddingLeft: isUploadHover ? 0 : "1rem",
paddingRight: isUploadHover ? 0 : "1rem",
width: isUploadHover ? "58px" : "calc(100% - 58px - 0.6rem)",
minWidth: isUploadHover ? "58px" : undefined,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "width .5s ease, padding .5s ease",
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
{!isUploadHover && <span>{t("landing.addFiles", "Add Files")}</span>}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 58px - 0.6rem)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)",
border: "1px solid var(--landing-button-border)",
borderRadius: "1rem",
height: "38px",
width: isUploadHover ? "calc(100% - 58px - 0.6rem)" : "58px",
minWidth: "58px",
paddingLeft: isUploadHover ? "1rem" : 0,
paddingRight: isUploadHover ? "1rem" : 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "width .5s ease, padding .5s ease",
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon={icons.uploadIconName} width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{terminology.uploadFromComputer}
</span>
)}
<LocalIcon
icon={icons.uploadIconName}
width="1.25rem"
height="1.25rem"
style={{ color: "var(--accent-interactive)" }}
/>
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
</Button>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }}
style={{ fontSize: ".8rem", textAlign: "center", marginTop: "0.5rem" }}
>
{terminology.dropFilesHere}
</span>
@@ -6,7 +6,10 @@
background: var(--file-card-bg);
border-radius: 0.0625rem;
cursor: pointer;
transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease;
transition:
box-shadow 0.18s ease,
outline-color 0.18s ease,
transform 0.18s ease;
max-width: 100%;
max-height: 100%;
overflow: visible;
@@ -45,8 +48,8 @@
}
.headerResting {
background: #3B4B6E; /* dark blue for unselected in light mode */
color: #FFFFFF;
background: #3b4b6e; /* dark blue for unselected in light mode */
color: #ffffff;
border-bottom: 1px solid var(--border-default);
}
@@ -66,7 +69,7 @@
/* Unsupported (but not errored) header appearance */
.headerUnsupported {
background: var(--unsupported-bar-bg); /* neutral gray */
color: #FFFFFF;
color: #ffffff;
border-bottom: 1px solid var(--unsupported-bar-border);
}
@@ -103,7 +106,7 @@
}
.headerIconButton {
color: #FFFFFF !important;
color: #ffffff !important;
}
/* Menu dropdown */
@@ -226,14 +229,13 @@
}
.pinned {
color: #FFC107 !important;
color: #ffc107 !important;
}
/* Unsupported file indicator */
.unsupportedPill {
margin-left: 1.75rem;
background: #6B7280;
background: #6b7280;
color: white;
padding: 4px 8px;
border-radius: 12px;
@@ -264,7 +266,8 @@
/* Animations */
@keyframes pulse {
0%, 100% {
0%,
100% {
opacity: 1;
}
50% {
@@ -288,15 +291,15 @@
DARK MODE OVERRIDES
========================= */
:global([data-mantine-color-scheme="dark"]) .card {
outline-color: #3A4047; /* deselected stroke */
outline-color: #3a4047; /* deselected stroke */
}
:global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] {
outline-color: #4B525A; /* selected stroke (subtle grey) */
outline-color: #4b525a; /* selected stroke (subtle grey) */
}
:global([data-mantine-color-scheme="dark"]) .headerResting {
background: #1F2329; /* requested default unselected color */
background: #1f2329; /* requested default unselected color */
color: var(--tool-header-text); /* #D0D6DC */
border-bottom-color: var(--tool-header-border); /* #3A4047 */
}
@@ -308,16 +311,16 @@
}
:global([data-mantine-color-scheme="dark"]) .title {
color: #D0D6DC; /* title text */
color: #d0d6dc; /* title text */
}
:global([data-mantine-color-scheme="dark"]) .meta {
color: #6B7280; /* subtitle text */
color: #6b7280; /* subtitle text */
}
/* Light mode selected header stroke override */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: #3B4B6E;
outline-color: #3b4b6e;
}
/* =========================
@@ -1,22 +1,19 @@
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import {
Text, Center, Box, LoadingOverlay, Stack
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { useViewer } from '@app/contexts/ViewerContext';
import { zipFileService } from '@app/services/zipFileService';
import { detectFileExtension } from '@app/utils/fileUtils';
import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail';
import AddFileCard from '@app/components/fileEditor/AddFileCard';
import FilePickerModal from '@app/components/shared/FilePickerModal';
import { FileId, StirlingFile } from '@app/types/fileContext';
import { alert } from '@app/components/toast';
import { downloadFile } from '@app/services/downloadService';
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from "@app/contexts/FileContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { zipFileService } from "@app/services/zipFileService";
import { detectFileExtension } from "@app/utils/fileUtils";
import FileEditorThumbnail from "@app/components/fileEditor/FileEditorThumbnail";
import AddFileCard from "@app/components/fileEditor/AddFileCard";
import FilePickerModal from "@app/components/shared/FilePickerModal";
import { FileId, StirlingFile } from "@app/types/fileContext";
import { alert } from "@app/components/toast";
import { downloadFile } from "@app/services/downloadService";
import { useFileEditorRightRailButtons } from "@app/components/fileEditor/fileEditorRightRailButtons";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
interface FileEditorProps {
onOpenPageEditor?: () => void;
@@ -25,16 +22,15 @@ interface FileEditorProps {
supportedExtensions?: string[];
}
const FileEditor = ({
toolMode = false,
supportedExtensions = ["pdf"]
}: FileEditorProps) => {
const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEditorProps) => {
// Utility function to check if a file extension is supported
const isFileSupported = useCallback((fileName: string): boolean => {
const extension = detectFileExtension(fileName);
return extension ? supportedExtensions.includes(extension) : false;
}, [supportedExtensions]);
const isFileSupported = useCallback(
(fileName: string): boolean => {
const extension = detectFileExtension(fileName);
return extension ? supportedExtensions.includes(extension) : false;
},
[supportedExtensions],
);
// Use optimized FileContext hooks
const { state, selectors } = useFileState();
@@ -62,11 +58,11 @@ const FileEditor = ({
const [_error, _setError] = useState<string | null>(null);
// Toast helpers
const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => {
const showStatus = useCallback((message: string, type: "neutral" | "success" | "warning" | "error" = "neutral") => {
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
}, []);
const showError = useCallback((message: string) => {
alert({ alertType: 'error', title: 'Error', body: message, expandable: true });
alert({ alertType: "error", title: "Error", body: message, expandable: true });
}, []);
const [selectionMode, setSelectionMode] = useState(toolMode);
@@ -76,7 +72,7 @@ const FileEditor = ({
// Compute effective max allowed files based on the active tool and mode
const maxAllowed = useMemo<number>(() => {
const rawMax = selectedTool?.maxFiles;
return (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax;
return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
}, [selectedTool?.maxFiles, toolMode]);
// Enable selection mode automatically in tool mode
@@ -104,8 +100,8 @@ const FileEditor = ({
try {
clearAllFileErrors();
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to clear file errors on select all:', error);
if (process.env.NODE_ENV === "development") {
console.warn("Failed to clear file errors on select all:", error);
}
}
}, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]);
@@ -115,8 +111,8 @@ const FileEditor = ({
try {
clearAllFileErrors();
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to clear file errors on deselect:', error);
if (process.env.NODE_ENV === "development") {
console.warn("Failed to clear file errors on deselect:", error);
}
}
}, [setSelectedFiles, clearAllFileErrors]);
@@ -137,69 +133,75 @@ const FileEditor = ({
// Process uploaded files using context
// ZIP extraction is now handled automatically in FileContext based on user preferences
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
_setError(null);
const handleFileUpload = useCallback(
async (uploadedFiles: File[]) => {
_setError(null);
try {
if (uploadedFiles.length > 0) {
// FileContext will automatically handle ZIP extraction based on user preferences
// - Respects autoUnzip setting
// - Respects autoUnzipFileLimit
// - HTML ZIPs stay intact
// - Non-ZIP files pass through unchanged
await addFiles(uploadedFiles, { selectFiles: true });
// After auto-selection, enforce maxAllowed if needed
if (Number.isFinite(maxAllowed)) {
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map(r => r.id);
if (nowSelectedIds.length > maxAllowed) {
setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
try {
if (uploadedFiles.length > 0) {
// FileContext will automatically handle ZIP extraction based on user preferences
// - Respects autoUnzip setting
// - Respects autoUnzipFileLimit
// - HTML ZIPs stay intact
// - Non-ZIP files pass through unchanged
await addFiles(uploadedFiles, { selectFiles: true });
// After auto-selection, enforce maxAllowed if needed
if (Number.isFinite(maxAllowed)) {
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map((r) => r.id);
if (nowSelectedIds.length > maxAllowed) {
setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
}
}
showStatus(`Added ${uploadedFiles.length} file(s)`, "success");
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to process files";
showError(errorMessage);
console.error("File processing error:", err);
}
},
[addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles],
);
const toggleFile = useCallback(
(fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeStirlingFileStubs.find((r) => r.id === fileId);
if (!targetRecord) return;
const contextFileId = fileId; // No need to create a new ID
const isSelected = currentSelectedIds.includes(contextFileId);
let newSelection: FileId[];
if (isSelected) {
// Remove file from selection
newSelection = currentSelectedIds.filter((id) => id !== contextFileId);
} else {
// Add file to selection
// Determine max files allowed from the active tool (negative or undefined means unlimited)
const rawMax = selectedTool?.maxFiles;
const maxAllowed = !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
if (maxAllowed === 1) {
// Only one file allowed -> replace selection with the new file
newSelection = [contextFileId];
} else {
// If at capacity, drop the oldest selected and append the new one
if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) {
newSelection = [...currentSelectedIds.slice(1), contextFileId];
} else {
newSelection = [...currentSelectedIds, contextFileId];
}
}
showStatus(`Added ${uploadedFiles.length} file(s)`, 'success');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
showError(errorMessage);
console.error('File processing error:', err);
}
}, [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles]);
const toggleFile = useCallback((fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
if (!targetRecord) return;
const contextFileId = fileId; // No need to create a new ID
const isSelected = currentSelectedIds.includes(contextFileId);
let newSelection: FileId[];
if (isSelected) {
// Remove file from selection
newSelection = currentSelectedIds.filter(id => id !== contextFileId);
} else {
// Add file to selection
// Determine max files allowed from the active tool (negative or undefined means unlimited)
const rawMax = selectedTool?.maxFiles;
const maxAllowed = (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax;
if (maxAllowed === 1) {
// Only one file allowed -> replace selection with the new file
newSelection = [contextFileId];
} else {
// If at capacity, drop the oldest selected and append the new one
if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) {
newSelection = [...currentSelectedIds.slice(1), contextFileId];
} else {
newSelection = [...currentSelectedIds, contextFileId];
}
}
}
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles]);
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
},
[setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles],
);
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
useEffect(() => {
@@ -208,154 +210,174 @@ const FileEditor = ({
}
}, [maxAllowed, selectedFileIds, setSelectedFiles]);
// File reordering handler for drag and drop
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
const currentIds = activeStirlingFileStubs.map(r => r.id);
const handleReorderFiles = useCallback(
(sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
const currentIds = activeStirlingFileStubs.map((r) => r.id);
// Find indices
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
const targetIndex = currentIds.findIndex(id => id === targetFileId);
// Find indices
const sourceIndex = currentIds.findIndex((id) => id === sourceFileId);
const targetIndex = currentIds.findIndex((id) => id === targetFileId);
if (sourceIndex === -1 || targetIndex === -1) {
console.warn('Could not find source or target file for reordering');
return;
}
// Handle multi-file selection reordering
const filesToMove = selectedFileIds.length > 1
? selectedFileIds.filter(id => currentIds.includes(id))
: [sourceFileId];
// Create new order
const newOrder = [...currentIds];
// Remove files to move from their current positions (in reverse order to maintain indices)
const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id))
.sort((a, b) => b - a); // Sort descending
sourceIndices.forEach(index => {
newOrder.splice(index, 1);
});
// Calculate insertion index after removals
let insertIndex = newOrder.findIndex(id => id === targetFileId);
if (insertIndex !== -1) {
// Determine if moving forward or backward
const isMovingForward = sourceIndex < targetIndex;
if (isMovingForward) {
// Moving forward: insert after target
insertIndex += 1;
} else {
// Moving backward: insert before target (insertIndex already correct)
if (sourceIndex === -1 || targetIndex === -1) {
console.warn("Could not find source or target file for reordering");
return;
}
} else {
// Target was moved, insert at end
insertIndex = newOrder.length;
}
// Insert files at the calculated position
newOrder.splice(insertIndex, 0, ...filesToMove);
// Handle multi-file selection reordering
const filesToMove =
selectedFileIds.length > 1 ? selectedFileIds.filter((id) => currentIds.includes(id)) : [sourceFileId];
// Update file order
reorderFiles(newOrder);
// Create new order
const newOrder = [...currentIds];
// Update status
const moveCount = filesToMove.length;
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeStirlingFileStubs, reorderFiles, _setStatus]);
// Remove files to move from their current positions (in reverse order to maintain indices)
const sourceIndices = filesToMove.map((id) => newOrder.findIndex((nId) => nId === id)).sort((a, b) => b - a); // Sort descending
sourceIndices.forEach((index) => {
newOrder.splice(index, 1);
});
// Calculate insertion index after removals
let insertIndex = newOrder.findIndex((id) => id === targetFileId);
if (insertIndex !== -1) {
// Determine if moving forward or backward
const isMovingForward = sourceIndex < targetIndex;
if (isMovingForward) {
// Moving forward: insert after target
insertIndex += 1;
} else {
// Moving backward: insert before target (insertIndex already correct)
}
} else {
// Target was moved, insert at end
insertIndex = newOrder.length;
}
// Insert files at the calculated position
newOrder.splice(insertIndex, 0, ...filesToMove);
// Update file order
reorderFiles(newOrder);
// Update status
const moveCount = filesToMove.length;
showStatus(`${moveCount > 1 ? `${moveCount} files` : "File"} reordered`);
},
[activeStirlingFileStubs, reorderFiles, _setStatus],
);
// File operations using context
const handleCloseFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
// Remove file from context but keep in storage (close, don't delete)
const contextFileId = record.id;
removeFiles([contextFileId], false);
const handleCloseFile = useCallback(
(fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
// Remove file from context but keep in storage (close, don't delete)
const contextFileId = record.id;
removeFiles([contextFileId], false);
// Remove from context selections
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
setSelectedFiles(currentSelected);
}
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
const handleDownloadFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
console.log('[FileEditor] handleDownloadFile called:', { fileId, hasRecord: !!record, hasFile: !!file, localFilePath: record?.localFilePath, isDirty: record?.isDirty });
if (record && file) {
const result = await downloadFile({
data: file,
filename: file.name,
localPath: record.localFilePath
});
console.log('[FileEditor] Download complete, checking dirty state:', { localFilePath: record.localFilePath, isDirty: record.isDirty, savedPath: result.savedPath });
// Mark file as clean after successful save to disk
if (result.savedPath) {
console.log('[FileEditor] Marking file as clean:', fileId);
fileActions.updateStirlingFileStub(fileId, {
localFilePath: record.localFilePath ?? result.savedPath,
isDirty: false
});
} else {
console.log('[FileEditor] Skipping clean mark:', { savedPath: result.savedPath, isDirty: record.isDirty });
// Remove from context selections
const currentSelected = selectedFileIds.filter((id) => id !== contextFileId);
setSelectedFiles(currentSelected);
}
}
}, [activeStirlingFileStubs, selectors, fileActions]);
},
[activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds],
);
const handleUnzipFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
try {
// Extract and store files using shared service method
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
if (result.success && result.extractedStubs.length > 0) {
// Add extracted file stubs to FileContext
await fileActions.addStirlingFileStubs(result.extractedStubs);
// Remove the original ZIP file
removeFiles([fileId], false);
alert({
alertType: 'success',
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
expandable: false,
durationMs: 3500
const handleDownloadFile = useCallback(
async (fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
console.log("[FileEditor] handleDownloadFile called:", {
fileId,
hasRecord: !!record,
hasFile: !!file,
localFilePath: record?.localFilePath,
isDirty: record?.isDirty,
});
if (record && file) {
const result = await downloadFile({
data: file,
filename: file.name,
localPath: record.localFilePath,
});
console.log("[FileEditor] Download complete, checking dirty state:", {
localFilePath: record.localFilePath,
isDirty: record.isDirty,
savedPath: result.savedPath,
});
// Mark file as clean after successful save to disk
if (result.savedPath) {
console.log("[FileEditor] Marking file as clean:", fileId);
fileActions.updateStirlingFileStub(fileId, {
localFilePath: record.localFilePath ?? result.savedPath,
isDirty: false,
});
} else {
console.log("[FileEditor] Skipping clean mark:", { savedPath: result.savedPath, isDirty: record.isDirty });
}
}
},
[activeStirlingFileStubs, selectors, fileActions],
);
const handleUnzipFile = useCallback(
async (fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
try {
// Extract and store files using shared service method
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
if (result.success && result.extractedStubs.length > 0) {
// Add extracted file stubs to FileContext
await fileActions.addStirlingFileStubs(result.extractedStubs);
// Remove the original ZIP file
removeFiles([fileId], false);
alert({
alertType: "success",
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
expandable: false,
durationMs: 3500,
});
} else {
alert({
alertType: "error",
title: `Failed to extract files from ${file.name}`,
body: result.errors.join("\n"),
expandable: true,
durationMs: 3500,
});
}
} catch (error) {
console.error("Failed to unzip file:", error);
alert({
alertType: 'error',
title: `Failed to extract files from ${file.name}`,
body: result.errors.join('\n'),
expandable: true,
durationMs: 3500
alertType: "error",
title: `Error unzipping ${file.name}`,
expandable: false,
durationMs: 3500,
});
}
} catch (error) {
console.error('Failed to unzip file:', error);
alert({
alertType: 'error',
title: `Error unzipping ${file.name}`,
expandable: false,
durationMs: 3500
});
}
}
}, [activeStirlingFileStubs, selectors, fileActions, removeFiles]);
},
[activeStirlingFileStubs, selectors, fileActions, removeFiles],
);
const handleViewFile = useCallback((fileId: FileId) => {
const index = activeStirlingFileStubs.findIndex(r => r.id === fileId);
if (index !== -1) {
setActiveFileId(fileId as string);
setActiveFileIndex(index);
navActions.setWorkbench('viewer');
}
}, [activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench]);
const handleViewFile = useCallback(
(fileId: FileId) => {
const index = activeStirlingFileStubs.findIndex((r) => r.id === fileId);
if (index !== -1) {
setActiveFileId(fileId as string);
setActiveFileIndex(index);
navActions.setWorkbench("viewer");
}
},
[activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench],
);
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
if (selectedFiles.length === 0) return;
@@ -365,91 +387,85 @@ const FileEditor = ({
// The files are already in FileContext, just need to add them to active files
showStatus(`Loaded ${selectedFiles.length} files from storage`);
} catch (err) {
console.error('Error loading files from storage:', err);
showError('Failed to load some files from storage');
console.error("Error loading files from storage:", err);
showError("Failed to load some files from storage");
}
}, []);
return (
<Dropzone
onDrop={handleFileUpload}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{
border: 'none',
border: "none",
borderRadius: 0,
backgroundColor: 'transparent'
backgroundColor: "transparent",
}}
activateOnClick={false}
activateOnDrag={true}
>
<Box pos="relative" style={{ overflow: 'auto' }}>
<Box pos="relative" style={{ overflow: "auto" }}>
<LoadingOverlay visible={state.ui.isProcessing} />
<Box p="md">
{activeStirlingFileStubs.length === 0 ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">
📁
</Text>
<Text c="dimmed">No files loaded</Text>
<Text size="sm" c="dimmed">
Upload PDF files, ZIP archives, or load from storage to get started
</Text>
</Stack>
</Center>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
rowGap: "1.5rem",
padding: "1rem",
pointerEvents: "auto",
}}
>
{/* Add File Card - only show when files exist */}
{activeStirlingFileStubs.length > 0 && <AddFileCard key="add-file-card" onFileSelect={handleFileUpload} />}
{activeStirlingFileStubs.map((record, index) => {
return (
<FileEditorThumbnail
key={record.id}
file={record}
index={index}
totalFiles={activeStirlingFileStubs.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onCloseFile={handleCloseFile}
onViewFile={handleViewFile}
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
/>
);
})}
</div>
)}
</Box>
{activeStirlingFileStubs.length === 0 ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text>
<Text c="dimmed">No files loaded</Text>
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
</Stack>
</Center>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
rowGap: '1.5rem',
padding: '1rem',
pointerEvents: 'auto'
}}
>
{/* Add File Card - only show when files exist */}
{activeStirlingFileStubs.length > 0 && (
<AddFileCard
key="add-file-card"
onFileSelect={handleFileUpload}
/>
)}
{activeStirlingFileStubs.map((record, index) => {
return (
<FileEditorThumbnail
key={record.id}
file={record}
index={index}
totalFiles={activeStirlingFileStubs.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onCloseFile={handleCloseFile}
onViewFile={handleViewFile}
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
/>
);
})}
</div>
)}
</Box>
{/* File Picker Modal */}
<FilePickerModal
opened={showFilePickerModal}
onClose={() => setShowFilePickerModal(false)}
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
onSelectFiles={handleLoadFromStorage}
/>
{/* File Picker Modal */}
<FilePickerModal
opened={showFilePickerModal}
onClose={() => setShowFilePickerModal(false)}
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
onSelectFiles={handleLoadFromStorage}
/>
</Box>
</Dropzone>
);
@@ -1,13 +1,11 @@
import React from 'react';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import React from "react";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
interface FileEditorFileNameProps {
file: StirlingFileStub;
}
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
<PrivateContent>{file.name}</PrivateContent>
);
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => <PrivateContent>{file.name}</PrivateContent>;
export default FileEditorFileName;
@@ -1,38 +1,36 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from '@mantine/core';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { alert } from '@app/components/toast';
import { useTranslation } from 'react-i18next';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import CloseIcon from '@mui/icons-material/Close';
import VisibilityIcon from '@mui/icons-material/Visibility';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import LinkIcon from '@mui/icons-material/Link';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { StirlingFileStub } from '@app/types/fileContext';
import { zipFileService } from '@app/services/zipFileService';
import styles from '@app/components/fileEditor/FileEditor.module.css';
import { useFileContext } from '@app/contexts/FileContext';
import { useFileState } from '@app/contexts/file/fileHooks';
import { FileId } from '@app/types/file';
import { formatFileSize } from '@app/utils/fileUtils';
import ToolChain from '@app/components/shared/ToolChain';
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
import { downloadFile } from '@app/services/downloadService';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import UploadToServerModal from '@app/components/shared/UploadToServerModal';
import ShareFileModal from '@app/components/shared/ShareFileModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { truncateCenter } from '@app/utils/textUtils';
import React, { useState, useCallback, useRef, useMemo } from "react";
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from "@mantine/core";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { alert } from "@app/components/toast";
import { useTranslation } from "react-i18next";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import CloseIcon from "@mui/icons-material/Close";
import VisibilityIcon from "@mui/icons-material/Visibility";
import UnarchiveIcon from "@mui/icons-material/Unarchive";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import LinkIcon from "@mui/icons-material/Link";
import PushPinIcon from "@mui/icons-material/PushPin";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { StirlingFileStub } from "@app/types/fileContext";
import { zipFileService } from "@app/services/zipFileService";
import styles from "@app/components/fileEditor/FileEditor.module.css";
import { useFileContext } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/file/fileHooks";
import { FileId } from "@app/types/file";
import { formatFileSize } from "@app/utils/fileUtils";
import ToolChain from "@app/components/shared/ToolChain";
import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu";
import { downloadFile } from "@app/services/downloadService";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
import ShareFileModal from "@app/components/shared/ShareFileModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { truncateCenter } from "@app/utils/textUtils";
interface FileEditorThumbnailProps {
file: StirlingFileStub;
@@ -69,14 +67,7 @@ const FileEditorThumbnail = ({
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const DownloadOutlinedIcon = icons.download;
const {
pinFile,
unpinFile,
isFilePinned,
activeFiles,
actions: fileActions,
openEncryptedUnlockPrompt,
} = useFileContext();
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions, openEncryptedUnlockPrompt } = useFileContext();
const { state, selectors } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
@@ -93,7 +84,7 @@ const FileEditorThumbnail = ({
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
return activeFiles.find(f => f.fileId === file.id);
return activeFiles.find((f) => f.fileId === file.id);
}, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
@@ -114,17 +105,17 @@ const FileEditorThumbnail = ({
}, [file.size]);
const extUpper = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
return (m?.[1] || '').toUpperCase();
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
return (m?.[1] || "").toUpperCase();
}, [file.name]);
const extLower = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
return (m?.[1] || '').toLowerCase();
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
return (m?.[1] || "").toLowerCase();
}, [file.name]);
const isCBZ = extLower === 'cbz';
const isCBR = extLower === 'cbr';
const isCBZ = extLower === "cbz";
const isCBR = extLower === "cbr";
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
@@ -137,71 +128,68 @@ const FileEditorThumbnail = ({
const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate);
const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf;
const pageLabel = useMemo(
() =>
pageCount > 0
? `${pageCount} ${pageCount === 1 ? 'Page' : 'Pages'}`
: '',
[pageCount]
);
const pageLabel = useMemo(() => (pageCount > 0 ? `${pageCount} ${pageCount === 1 ? "Page" : "Pages"}` : ""), [pageCount]);
const dateLabel = useMemo(() => {
const d = new Date(file.lastModified);
if (Number.isNaN(d.getTime())) return '';
if (Number.isNaN(d.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: '2-digit',
year: 'numeric',
month: "short",
day: "2-digit",
year: "numeric",
}).format(d);
}, [file.lastModified]);
// ---- Drag & drop wiring ----
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
if (!element) return;
const fileElementRef = useCallback(
(element: HTMLDivElement | null) => {
if (!element) return;
dragElementRef.current = element;
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: 'file',
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id] // Always drag only this file, ignore selection state
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
}
});
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: "file",
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id], // Always drag only this file, ignore selection state
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'file',
fileId: file.id
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === 'file' && sourceData.fileId !== file.id;
},
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === 'file' && onReorderFiles) {
const sourceFileId = sourceData.fileId as FileId;
const selectedFileIds = sourceData.selectedFiles as FileId[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
}
}
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: "file",
fileId: file.id,
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === "file" && sourceData.fileId !== file.id;
},
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === "file" && onReorderFiles) {
const sourceFileId = sourceData.fileId as FileId;
const selectedFileIds = sourceData.selectedFiles as FileId[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
}
},
});
return () => {
dragCleanup();
dropCleanup();
};
}, [file.id, file.name, selectedFiles, onReorderFiles]);
return () => {
dragCleanup();
dropCleanup();
};
},
[file.id, file.name, selectedFiles, onReorderFiles],
);
// Handle close with confirmation
const handleCloseWithConfirmation = useCallback(() => {
@@ -210,7 +198,7 @@ const FileEditorThumbnail = ({
const handleConfirmClose = useCallback(() => {
onCloseFile(file.id);
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
alert({ alertType: "neutral", title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
setShowCloseModal(false);
}, [file.id, file.name, onCloseFile]);
@@ -221,12 +209,12 @@ const FileEditorThumbnail = ({
const result = await downloadFile({
data: fileToSave,
filename: file.name,
localPath: file.localFilePath
localPath: file.localFilePath,
});
if (!result.cancelled && result.savedPath) {
fileActions.updateStirlingFileStub(file.id, {
localFilePath: file.localFilePath ?? result.savedPath,
isDirty: false
isDirty: false,
});
} else if (result.cancelled) {
setShowCloseModal(false);
@@ -234,14 +222,14 @@ const FileEditorThumbnail = ({
}
} catch (error) {
console.error(`Failed to save ${file.name}:`, error);
alert({ alertType: 'error', title: 'Save failed', body: `Could not save ${file.name}`, expandable: true });
alert({ alertType: "error", title: "Save failed", body: `Could not save ${file.name}`, expandable: true });
setShowCloseModal(false);
return;
}
}
// Then close
onCloseFile(file.id);
alert({ alertType: 'success', title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 });
alert({ alertType: "success", title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 });
setShowCloseModal(false);
}, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]);
@@ -250,95 +238,110 @@ const FileEditorThumbnail = ({
}, []);
// Build hover menu actions
const hoverActions = useMemo<HoverAction[]>(() => [
{
id: 'view',
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
label: t('openInViewer', 'Open in Viewer'),
onClick: (e) => {
e.stopPropagation();
onViewFile(file.id);
const hoverActions = useMemo<HoverAction[]>(
() => [
{
id: "view",
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
label: t("openInViewer", "Open in Viewer"),
onClick: (e) => {
e.stopPropagation();
onViewFile(file.id);
},
},
},
{
id: 'download',
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
label: terminology.download,
onClick: (e) => {
e.stopPropagation();
onDownloadFile(file.id);
{
id: "download",
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
label: terminology.download,
onClick: (e) => {
e.stopPropagation();
onDownloadFile(file.id);
},
},
},
...(canUpload || canShare
? [
...(canUpload ? [{
id: 'upload',
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
label: isUploaded
? t('fileManager.updateOnServer', 'Update on Server')
: t('fileManager.uploadToServer', 'Upload to Server'),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowUploadModal(true);
},
}] : []),
...(canShare ? [{
id: 'share',
icon: <LinkIcon style={{ fontSize: 20 }} />,
label: t('fileManager.share', 'Share'),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowShareModal(true);
},
}] : []),
]
: []),
{
id: 'unzip',
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
label: t('fileManager.unzip', 'Unzip'),
onClick: (e) => {
e.stopPropagation();
if (onUnzipFile) {
onUnzipFile(file.id);
alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
}
...(canUpload || canShare
? [
...(canUpload
? [
{
id: "upload",
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
label: isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowUploadModal(true);
},
},
]
: []),
...(canShare
? [
{
id: "share",
icon: <LinkIcon style={{ fontSize: 20 }} />,
label: t("fileManager.share", "Share"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowShareModal(true);
},
},
]
: []),
]
: []),
{
id: "unzip",
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
label: t("fileManager.unzip", "Unzip"),
onClick: (e) => {
e.stopPropagation();
if (onUnzipFile) {
onUnzipFile(file.id);
alert({ alertType: "success", title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
}
},
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
},
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
},
{
id: 'close',
icon: <CloseIcon style={{ fontSize: 20 }} />,
label: t('close', 'Close'),
onClick: (e) => {
e.stopPropagation();
handleCloseWithConfirmation();
{
id: "close",
icon: <CloseIcon style={{ fontSize: 20 }} />,
label: t("close", "Close"),
onClick: (e) => {
e.stopPropagation();
handleCloseWithConfirmation();
},
color: "red",
},
color: 'red',
}
], [
t,
file.id,
file.name,
isZipFile,
isCBZ,
isCBR,
terminology,
onViewFile,
onDownloadFile,
onUnzipFile,
handleCloseWithConfirmation,
canUpload,
canShare,
isUploaded
]);
],
[
t,
file.id,
file.name,
isZipFile,
isCBZ,
isCBR,
terminology,
onViewFile,
onDownloadFile,
onUnzipFile,
handleCloseWithConfirmation,
canUpload,
canShare,
isUploaded,
],
);
// ---- Card interactions ----
const handleCardClick = () => {
if (!isSupported) return;
// Clear error state if file has an error (click to clear error)
if (hasError) {
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
try {
fileActions.clearFileError(file.id);
} catch (_e) {
void _e;
}
}
if (isSharedFile && !sharedEditNoticeShownRef.current) {
sharedEditNoticeShownRef.current = true;
@@ -359,7 +362,6 @@ const FileEditorThumbnail = ({
return isSelected ? styles.headerSelected : styles.headerResting;
};
return (
<div
ref={fileElementRef}
@@ -369,7 +371,7 @@ const FileEditorThumbnail = ({
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
style={{opacity: isDragging ? 0.9 : 1}}
style={{ opacity: isDragging ? 0.9 : 1 }}
tabIndex={0}
role="listitem"
aria-selected={isSelected}
@@ -379,15 +381,12 @@ const FileEditorThumbnail = ({
onDoubleClick={handleCardDoubleClick}
>
{/* Header bar */}
<div
className={`${styles.header} ${getHeaderClassName()}`}
data-has-error={hasError}
>
<div className={`${styles.header} ${getHeaderClassName()}`} data-has-error={hasError}>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{hasError ? (
<div className={styles.errorPill}>
<span>{t('error._value', 'Error')}</span>
<span>{t("error._value", "Error")}</span>
</div>
) : isSupported ? (
<CheckboxIndicator
@@ -397,9 +396,7 @@ const FileEditorThumbnail = ({
/>
) : (
<div className={styles.unsupportedPill}>
<span>
{t('unsupported', 'Unsupported')}
</span>
<span>{t("unsupported", "Unsupported")}</span>
</div>
)}
</div>
@@ -412,9 +409,9 @@ const FileEditorThumbnail = ({
{/* Action buttons group */}
<div className={styles.headerActions}>
{isEncrypted && (
<Tooltip label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}>
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}>
<ActionIcon
aria-label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}
aria-label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
@@ -427,9 +424,17 @@ const FileEditorThumbnail = ({
</Tooltip>
)}
{/* Pin/Unpin icon */}
<Tooltip label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}>
<Tooltip
label={
isPinned ? t("unpin", "Unpin File (replace after tool run)") : t("pin", "Pin File (keep active after tool run)")
}
>
<ActionIcon
aria-label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}
aria-label={
isPinned
? t("unpin", "Unpin File (replace after tool run)")
: t("pin", "Pin File (keep active after tool run)")
}
variant="subtle"
className={isPinned ? styles.pinned : styles.headerIconButton}
data-tour="file-card-pin"
@@ -438,10 +443,10 @@ const FileEditorThumbnail = ({
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
alert({ alertType: "neutral", title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
} else {
pinFile(actualFile);
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
alert({ alertType: "success", title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
}
}
}}
@@ -454,35 +459,30 @@ const FileEditorThumbnail = ({
{/* Title + meta line */}
<div
style={{
padding: '0.5rem',
textAlign: 'center',
background: 'var(--file-card-bg)',
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}>
style={{
padding: "0.5rem",
textAlign: "center",
background: "var(--file-card-bg)",
marginTop: "0.5rem",
marginBottom: "0.5rem",
}}
>
<Text size="lg" fw={700} className={styles.title} title={file.name}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
</Text>
<Text
size="sm"
c="dimmed"
className={styles.meta}
lineClamp={3}
title={`${extUpper || 'FILE'}${prettySize}`}
>
<Text size="sm" c="dimmed" className={styles.meta} lineClamp={3} title={`${extUpper || "FILE"}${prettySize}`}>
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
{`v${file.versionNumber} - `}
{dateLabel}
{extUpper ? ` - ${extUpper} file` : ''}
{pageLabel ? ` - ${pageLabel}` : ''}
{extUpper ? ` - ${extUpper} file` : ""}
{pageLabel ? ` - ${pageLabel}` : ""}
</Text>
</div>
{/* Preview area */}
<div
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
style={isSupported || hasError ? undefined : { filter: "grayscale(80%)", opacity: 0.6 }}
>
<div className={styles.previewPaper}>
{file.thumbnailUrl ? (
@@ -495,27 +495,29 @@ const FileEditorThumbnail = ({
decoding="async"
onError={(e) => {
const img = e.currentTarget;
img.style.display = 'none';
img.parentElement?.setAttribute('data-thumb-missing', 'true');
img.style.display = "none";
img.parentElement?.setAttribute("data-thumb-missing", "true");
}}
style={{
maxWidth: '80%',
maxHeight: '80%',
objectFit: 'contain',
borderRadius: 0,
background: '#ffffff',
border: '1px solid var(--border-default)',
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
alignSelf: 'start'
}}
/>
maxWidth: "80%",
maxHeight: "80%",
objectFit: "contain",
borderRadius: 0,
background: "#ffffff",
border: "1px solid var(--border-default)",
display: "block",
marginLeft: "auto",
marginRight: "auto",
alignSelf: "start",
}}
/>
</PrivateContent>
) : file.type?.startsWith('application/pdf') ? (
<Stack align="center" justify="center" gap="xs" style={{ height: '100%' }}>
) : file.type?.startsWith("application/pdf") ? (
<Stack align="center" justify="center" gap="xs" style={{ height: "100%" }}>
<Loader size="sm" />
<Text size="xs" c="dimmed">Loading thumbnail...</Text>
<Text size="xs" c="dimmed">
Loading thumbnail...
</Text>
</Stack>
) : null}
</div>
@@ -527,74 +529,72 @@ const FileEditorThumbnail = ({
{/* Tool chain display at bottom */}
{file.toolHistory && (
<div style={{
position: 'absolute',
bottom: '4px',
left: '4px',
right: '4px',
padding: '4px 6px',
textAlign: 'center',
fontWeight: 600,
overflow: 'hidden',
whiteSpace: 'nowrap'
}}>
<div
style={{
position: "absolute",
bottom: "4px",
left: "4px",
right: "4px",
padding: "4px 6px",
textAlign: "center",
fontWeight: 600,
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
<ToolChain
toolChain={file.toolHistory}
displayStyle="text"
size="xs"
maxWidth={'100%'}
color='var(--mantine-color-gray-7)'
maxWidth={"100%"}
color="var(--mantine-color-gray-7)"
/>
</div>
)}
</div>
{/* Hover Menu */}
<HoverActionMenu
show={showHoverMenu || isMobile}
actions={hoverActions}
position="outside"
/>
<HoverActionMenu show={showHoverMenu || isMobile} actions={hoverActions} position="outside" />
{/* Close Confirmation Modal */}
<Modal
opened={showCloseModal}
onClose={handleCancelClose}
title={t('confirmClose', 'Confirm Close')}
title={t("confirmClose", "Confirm Close")}
centered
size="auto"
>
<Stack gap="md">
{file.isDirty && file.localFilePath ? (
<>
<Text size="md">{t('confirmCloseUnsaved', 'This file has unsaved changes.')}</Text>
<Text size="md">{t("confirmCloseUnsaved", "This file has unsaved changes.")}</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
{t('confirmCloseCancel', 'Cancel')}
{t("confirmCloseCancel", "Cancel")}
</Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}>
{t('confirmCloseDiscard', 'Discard changes and close')}
{t("confirmCloseDiscard", "Discard changes and close")}
</Button>
<Button variant="filled" onClick={handleSaveAndClose}>
{t('confirmCloseSave', 'Save and close')}
{t("confirmCloseSave", "Save and close")}
</Button>
</Group>
</>
) : (
<>
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
<Text size="md">{t("confirmCloseMessage", "Are you sure you want to close this file?")}</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
{t('confirmCloseCancel', 'Cancel')}
{t("confirmCloseCancel", "Cancel")}
</Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}>
{t('confirmCloseConfirm', 'Close File')}
{t("confirmCloseConfirm", "Close File")}
</Button>
</Group>
</>
@@ -604,39 +604,27 @@ const FileEditorThumbnail = ({
<Modal
opened={showSharedEditNotice}
onClose={() => setShowSharedEditNotice(false)}
title={t('fileManager.sharedEditNoticeTitle', 'Read-only server copy')}
title={t("fileManager.sharedEditNoticeTitle", "Read-only server copy")}
centered
size="auto"
>
<Stack gap="md">
<Text size="sm">
{t(
'fileManager.sharedEditNoticeBody',
'You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.'
"fileManager.sharedEditNoticeBody",
"You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.",
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button onClick={() => setShowSharedEditNotice(false)}>
{t('fileManager.sharedEditNoticeConfirm', 'Got it')}
{t("fileManager.sharedEditNoticeConfirm", "Got it")}
</Button>
</Group>
</Stack>
</Modal>
{canUpload && (
<UploadToServerModal
opened={showUploadModal}
onClose={() => setShowUploadModal(false)}
file={file}
/>
)}
{canShare && (
<ShareFileModal
opened={showShareModal}
onClose={() => setShowShareModal(false)}
file={file}
/>
)}
{canUpload && <UploadToServerModal opened={showUploadModal} onClose={() => setShowUploadModal(false)} file={file} />}
{canShare && <ShareFileModal opened={showShareModal} onClose={() => setShowShareModal(false)} file={file} />}
</div>
);
};
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useRightRailButtons, RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useRightRailButtons, RightRailButtonWithAction } from "@app/hooks/useRightRailButtons";
import LocalIcon from "@app/components/shared/LocalIcon";
interface FileEditorRightRailButtonsParams {
totalItems: number;
@@ -20,41 +20,44 @@ export function useFileEditorRightRailButtons({
}: FileEditorRightRailButtonsParams) {
const { t, i18n } = useTranslation();
const buttons = useMemo<RightRailButtonWithAction[]>(() => [
{
id: 'file-select-all',
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
tooltip: t('rightRail.selectAll', 'Select All'),
ariaLabel: typeof t === 'function' ? t('rightRail.selectAll', 'Select All') : 'Select All',
section: 'top' as const,
order: 10,
disabled: totalItems === 0 || selectedCount === totalItems,
visible: totalItems > 0,
onClick: onSelectAll,
},
{
id: 'file-deselect-all',
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
tooltip: t('rightRail.deselectAll', 'Deselect All'),
ariaLabel: typeof t === 'function' ? t('rightRail.deselectAll', 'Deselect All') : 'Deselect All',
section: 'top' as const,
order: 20,
disabled: selectedCount === 0,
visible: totalItems > 0,
onClick: onDeselectAll,
},
{
id: 'file-close-selected',
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
tooltip: t('rightRail.closeSelected', 'Close Selected Files'),
ariaLabel: typeof t === 'function' ? t('rightRail.closeSelected', 'Close Selected Files') : 'Close Selected Files',
section: 'top' as const,
order: 30,
disabled: selectedCount === 0,
visible: totalItems > 0,
onClick: onCloseSelected,
},
], [t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected]);
const buttons = useMemo<RightRailButtonWithAction[]>(
() => [
{
id: "file-select-all",
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
tooltip: t("rightRail.selectAll", "Select All"),
ariaLabel: typeof t === "function" ? t("rightRail.selectAll", "Select All") : "Select All",
section: "top" as const,
order: 10,
disabled: totalItems === 0 || selectedCount === totalItems,
visible: totalItems > 0,
onClick: onSelectAll,
},
{
id: "file-deselect-all",
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
tooltip: t("rightRail.deselectAll", "Deselect All"),
ariaLabel: typeof t === "function" ? t("rightRail.deselectAll", "Deselect All") : "Deselect All",
section: "top" as const,
order: 20,
disabled: selectedCount === 0,
visible: totalItems > 0,
onClick: onDeselectAll,
},
{
id: "file-close-selected",
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
tooltip: t("rightRail.closeSelected", "Close Selected Files"),
ariaLabel: typeof t === "function" ? t("rightRail.closeSelected", "Close Selected Files") : "Close Selected Files",
section: "top" as const,
order: 30,
disabled: selectedCount === 0,
visible: totalItems > 0,
onClick: onCloseSelected,
},
],
[t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected],
);
useRightRailButtons(buttons);
}
@@ -1,12 +1,12 @@
import React from 'react';
import { Stack, Box, Text, Button, ActionIcon, Center } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useTranslation } from 'react-i18next';
import { getFileSize } from '@app/utils/fileUtils';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import React from "react";
import { Stack, Box, Text, Button, ActionIcon, Center } from "@mantine/core";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { useTranslation } from "react-i18next";
import { getFileSize } from "@app/utils/fileUtils";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
interface CompactFileDetailsProps {
currentFile: StirlingFileStub | null;
@@ -29,47 +29,56 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
isAnimating,
onPrevious,
onNext,
onOpenFiles
onOpenFiles,
}) => {
const { t } = useTranslation();
const hasSelection = selectedFiles.length > 0;
const hasMultipleFiles = numberOfFiles > 1;
const showOwner = Boolean(
currentFile &&
(currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink)
currentFile && (currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink),
);
const ownerLabel = currentFile
? currentFile.remoteOwnerUsername || t('fileManager.ownerUnknown', 'Unknown')
: '';
const ownerLabel = currentFile ? currentFile.remoteOwnerUsername || t("fileManager.ownerUnknown", "Unknown") : "";
return (
<Stack gap="xs" style={{ height: '100%' }}>
<Stack gap="xs" style={{ height: "100%" }}>
{/* Compact mobile layout */}
<Box style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<Box style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
{/* Small preview */}
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box
style={{
width: "7.5rem",
height: "9.375rem",
flexShrink: 0,
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{currentFile && thumbnail ? (
<PrivateContent>
<img
src={thumbnail}
alt={currentFile.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: '0.25rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain",
borderRadius: "0.25rem",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
}}
/>
</PrivateContent>
) : currentFile ? (
<Center style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 4
}}>
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
<Center
style={{
width: "100%",
height: "100%",
backgroundColor: "var(--mantine-color-gray-1)",
borderRadius: 4,
}}
>
<PictureAsPdfIcon style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }} />
</Center>
) : null}
</Box>
@@ -77,10 +86,10 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
<PrivateContent>{currentFile ? currentFile.name : 'No file selected'}</PrivateContent>
<PrivateContent>{currentFile ? currentFile.name : "No file selected"}</PrivateContent>
</Text>
<Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ''}
{currentFile ? getFileSize(currentFile) : ""}
{selectedFiles.length > 1 && `${selectedFiles.length} files`}
{currentFile && ` • v${currentFile.versionNumber || 1}`}
</Text>
@@ -92,33 +101,23 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* Compact tool chain for mobile */}
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
<Text size="xs" c="dimmed">
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join('')}
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join("")}
</Text>
)}
{currentFile && showOwner && (
<Text size="xs" c="dimmed">
{t('fileManager.owner', 'Owner')}: {ownerLabel}
{t("fileManager.owner", "Owner")}: {ownerLabel}
</Text>
)}
</Box>
{/* Navigation arrows for multiple files */}
{hasMultipleFiles && (
<Box style={{ display: 'flex', gap: '0.25rem' }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={onPrevious}
disabled={isAnimating}
>
<Box style={{ display: "flex", gap: "0.25rem" }}>
<ActionIcon variant="subtle" size="sm" onClick={onPrevious} disabled={isAnimating}>
<ChevronLeftIcon style={{ fontSize: 16 }} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={onNext}
disabled={isAnimating}
>
<ActionIcon variant="subtle" size="sm" onClick={onNext} disabled={isAnimating}>
<ChevronRightIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Box>
@@ -132,14 +131,13 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
disabled={!hasSelection}
fullWidth
style={{
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
color: 'white'
backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
color: "white",
}}
>
{selectedFiles.length > 1
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
: t('fileManager.openFile', 'Open File')
}
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
: t("fileManager.openFile", "Open File")}
</Button>
</Stack>
);
@@ -1,79 +1,85 @@
import React from 'react';
import { Grid } from '@mantine/core';
import FileSourceButtons from '@app/components/fileManager/FileSourceButtons';
import FileDetails from '@app/components/fileManager/FileDetails';
import SearchInput from '@app/components/fileManager/SearchInput';
import FileListArea from '@app/components/fileManager/FileListArea';
import FileActions from '@app/components/fileManager/FileActions';
import HiddenFileInput from '@app/components/fileManager/HiddenFileInput';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import React from "react";
import { Grid } from "@mantine/core";
import FileSourceButtons from "@app/components/fileManager/FileSourceButtons";
import FileDetails from "@app/components/fileManager/FileDetails";
import SearchInput from "@app/components/fileManager/SearchInput";
import FileListArea from "@app/components/fileManager/FileListArea";
import FileActions from "@app/components/fileManager/FileActions";
import HiddenFileInput from "@app/components/fileManager/HiddenFileInput";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
const DesktopLayout: React.FC = () => {
const {
activeSource,
recentFiles,
modalHeight,
} = useFileManagerContext();
const { activeSource, recentFiles, modalHeight } = useFileManagerContext();
return (
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: 'nowrap', minWidth: 0 }}>
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: "nowrap", minWidth: 0 }}>
{/* Column 1: File Sources */}
<Grid.Col span="content" p="lg" style={{
minWidth: '13.625rem',
width: '13.625rem',
flexShrink: 0,
height: '100%',
}} data-tour="file-sources">
<Grid.Col
span="content"
p="lg"
style={{
minWidth: "13.625rem",
width: "13.625rem",
flexShrink: 0,
height: "100%",
}}
data-tour="file-sources"
>
<FileSourceButtons />
</Grid.Col>
{/* Column 2: File List */}
<Grid.Col span="auto" style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
minWidth: 0,
flex: '1 1 0px'
}}>
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--bg-file-list)',
border: '1px solid var(--mantine-color-gray-2)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
overflow: 'hidden'
}}>
{activeSource === 'recent' && (
<Grid.Col
span="auto"
style={{
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
minWidth: 0,
flex: "1 1 0px",
}}
>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
backgroundColor: "var(--bg-file-list)",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
overflow: "hidden",
}}
>
{activeSource === "recent" && (
<>
<div style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-3)'
}}>
<div
style={{
flexShrink: 0,
borderBottom: "1px solid var(--mantine-color-gray-3)",
}}
>
<SearchInput />
</div>
<div style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-3)'
}}>
<div
style={{
flexShrink: 0,
borderBottom: "1px solid var(--mantine-color-gray-3)",
}}
>
<FileActions />
</div>
</>
)}
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<FileListArea
scrollAreaHeight={activeSource === 'recent' && recentFiles.length > 0
? `calc(${modalHeight} - 7rem)`
: '100%'}
scrollAreaHeight={activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%"}
scrollAreaStyle={{
height: activeSource === 'recent' && recentFiles.length > 0
? `calc(${modalHeight} - 7rem)`
: '100%',
backgroundColor: 'transparent',
border: 'none',
borderRadius: 0
height: activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%",
backgroundColor: "transparent",
border: "none",
borderRadius: 0,
}}
/>
</div>
@@ -81,14 +87,18 @@ const DesktopLayout: React.FC = () => {
</Grid.Col>
{/* Column 3: File Details */}
<Grid.Col p="xl" span="content" style={{
minWidth: '25rem',
width: '25rem',
flexShrink: 0,
height: '100%',
maxWidth: '18rem'
}}>
<div style={{ height: '100%', overflow: 'hidden' }}>
<Grid.Col
p="xl"
span="content"
style={{
minWidth: "25rem",
width: "25rem",
flexShrink: 0,
height: "100%",
maxWidth: "18rem",
}}
>
<div style={{ height: "100%", overflow: "hidden" }}>
<FileDetails />
</div>
</Grid.Col>
@@ -1,7 +1,7 @@
import React from 'react';
import { Stack, Text, useMantineTheme, alpha } from '@mantine/core';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useTranslation } from 'react-i18next';
import React from "react";
import { Stack, Text, useMantineTheme, alpha } from "@mantine/core";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import { useTranslation } from "react-i18next";
interface DragOverlayProps {
isVisible: boolean;
@@ -16,29 +16,29 @@ const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
return (
<div
style={{
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: alpha(theme.colors.blue[6], 0.1),
border: `0.125rem dashed ${theme.colors.blue[6]}`,
borderRadius: '1.875rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: "1.875rem",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
pointerEvents: 'none'
pointerEvents: "none",
}}
>
<Stack align="center" gap="md">
<UploadFileIcon style={{ fontSize: '4rem', color: theme.colors.blue[6] }} />
<UploadFileIcon style={{ fontSize: "4rem", color: theme.colors.blue[6] }} />
<Text size="xl" fw={500} c="blue.6">
{t('fileManager.dropFilesHere', 'Drop files here to upload')}
{t("fileManager.dropFilesHere", "Drop files here to upload")}
</Text>
</Stack>
</div>
);
};
export default DragOverlay;
export default DragOverlay;
@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core';
import HistoryIcon from '@mui/icons-material/History';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import React, { useState } from "react";
import { Button, Group, Text, Stack, useMantineColorScheme } from "@mantine/core";
import HistoryIcon from "@mui/icons-material/History";
import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useLogoAssets } from "@app/hooks/useLogoAssets";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
const EmptyFilesState: React.FC = () => {
const { t } = useTranslation();
@@ -24,92 +24,90 @@ const EmptyFilesState: React.FC = () => {
return (
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem'
height: "100%",
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
}}
>
{/* Container */}
<div
style={{
backgroundColor: 'transparent',
padding: '3rem 2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5rem',
minWidth: '20rem',
maxWidth: '28rem',
width: '100%'
backgroundColor: "transparent",
padding: "3rem 2rem",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "1.5rem",
minWidth: "20rem",
maxWidth: "28rem",
width: "100%",
}}
>
{/* No Recent Files Message */}
<Stack align="center" gap="sm">
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
<HistoryIcon style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }} />
<Text c="dimmed" ta="center" size="lg">
{t('fileManager.noRecentFiles', 'No recent files')}
{t("fileManager.noRecentFiles", "No recent files")}
</Text>
</Stack>
{/* Stirling PDF Logo */}
<Group gap="xs" align="center">
<img
src={colorScheme === 'dark' ? wordmark.white : wordmark.grey}
src={colorScheme === "dark" ? wordmark.white : wordmark.grey}
alt="Stirling PDF"
style={{ height: '2.2rem', width: 'auto' }}
style={{ height: "2.2rem", width: "auto" }}
/>
</Group>
{/* Upload Button */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
marginTop: '0.5rem',
marginBottom: '0.5rem'
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
marginTop: "0.5rem",
marginBottom: "0.5rem",
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--bg-file-manager)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: isUploadHover ? '2rem' : '1rem',
height: '38px',
width: isUploadHover ? '100%' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease, border-radius .5s ease'
backgroundColor: "var(--bg-file-manager)",
color: "var(--landing-button-color)",
border: "1px solid var(--landing-button-border)",
borderRadius: isUploadHover ? "2rem" : "1rem",
height: "38px",
width: isUploadHover ? "100%" : "58px",
minWidth: "58px",
paddingLeft: isUploadHover ? "1rem" : 0,
paddingRight: isUploadHover ? "1rem" : 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "width .5s ease, padding .5s ease, border-radius .5s ease",
}}
onClick={handleUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon={icons.uploadIconName} width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{terminology.uploadFromComputer}
</span>
)}
<LocalIcon
icon={icons.uploadIconName}
width="1.25rem"
height="1.25rem"
style={{ color: "var(--accent-interactive)" }}
/>
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
</Button>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem', textAlign: 'center' }}
>
<span className="text-[var(--accent-interactive)]" style={{ fontSize: ".8rem", textAlign: "center" }}>
{terminology.dropFilesHere}
</span>
</div>
@@ -30,9 +30,8 @@ const FileActions: React.FC = () => {
onDownloadSelected,
refreshRecentFiles,
storageFilter,
onStorageFilterChange
} =
useFileManagerContext();
onStorageFilterChange,
} = useFileManagerContext();
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
@@ -42,11 +41,11 @@ const FileActions: React.FC = () => {
{ value: "all", label: t("fileManager.filterAll", "All") },
{ value: "local", label: t("fileManager.filterLocal", "Local") },
{ value: "sharedWithMe", label: t("fileManager.filterSharedWithMe", "Shared with me") },
{ value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") }
{ value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") },
]
: [
{ value: "all", label: t("fileManager.filterAll", "All") },
{ value: "local", label: t("fileManager.filterLocal", "Local") }
{ value: "local", label: t("fileManager.filterLocal", "Local") },
];
useEffect(() => {
if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) {
@@ -56,10 +55,8 @@ const FileActions: React.FC = () => {
const hasSelection = selectedFileIds.length > 0;
const hasOnlyOwnedSelection = selectedFiles.every((file) => file.remoteOwnedByCurrentUser !== false);
const hasDownloadAccess = selectedFiles.every((file) => {
const role = (file.remoteOwnedByCurrentUser !== false
? 'editor'
: (file.remoteAccessRole ?? 'viewer')).toLowerCase();
return role === 'editor' || role === 'commenter' || role === 'viewer';
const role = (file.remoteOwnedByCurrentUser !== false ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase();
return role === "editor" || role === "commenter" || role === "viewer";
});
const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection;
const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
@@ -80,7 +77,6 @@ const FileActions: React.FC = () => {
}
};
// Only show actions if there are files
if (recentFiles.length === 0) {
return null;
@@ -120,9 +116,7 @@ const FileActions: React.FC = () => {
<SegmentedControl
size="xs"
value={storageFilter}
onChange={(value) =>
onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")
}
onChange={(value) => onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")}
data={storageFilterOptions}
/>
)}
@@ -1,19 +1,17 @@
import React, { useEffect, useState } from 'react';
import { Stack, Button, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useIndexedDBThumbnail } from '@app/hooks/useIndexedDBThumbnail';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import FilePreview from '@app/components/shared/FilePreview';
import FileInfoCard from '@app/components/fileManager/FileInfoCard';
import CompactFileDetails from '@app/components/fileManager/CompactFileDetails';
import React, { useEffect, useState } from "react";
import { Stack, Button, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import FilePreview from "@app/components/shared/FilePreview";
import FileInfoCard from "@app/components/fileManager/FileInfoCard";
import CompactFileDetails from "@app/components/fileManager/CompactFileDetails";
interface FileDetailsProps {
compact?: boolean;
}
const FileDetails: React.FC<FileDetailsProps> = ({
compact = false
}) => {
const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
const { t } = useTranslation();
const [currentFileIndex, setCurrentFileIndex] = useState(0);
@@ -35,7 +33,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
if (isAnimating) return;
setIsAnimating(true);
setTimeout(() => {
setCurrentFileIndex(prev => prev > 0 ? prev - 1 : selectedFiles.length - 1);
setCurrentFileIndex((prev) => (prev > 0 ? prev - 1 : selectedFiles.length - 1));
setIsAnimating(false);
}, 150);
};
@@ -44,7 +42,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
if (isAnimating) return;
setIsAnimating(true);
setTimeout(() => {
setCurrentFileIndex(prev => prev < selectedFiles.length - 1 ? prev + 1 : 0);
setCurrentFileIndex((prev) => (prev < selectedFiles.length - 1 ? prev + 1 : 0));
setIsAnimating(false);
}, 150);
};
@@ -75,7 +73,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
return (
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`} justify="flex-start">
{/* Section 1: Thumbnail Preview */}
<Box style={{ width: '100%', height: 'min(35vh, 280px)', textAlign: 'center', flexShrink: 0 }}>
<Box style={{ width: "100%", height: "min(35vh, 280px)", textAlign: "center", flexShrink: 0 }}>
<FilePreview
file={currentFile}
thumbnail={getCurrentThumbnail()}
@@ -89,10 +87,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
</Box>
{/* Section 2: File Details */}
<FileInfoCard
currentFile={currentFile}
modalHeight={modalHeight}
/>
<FileInfoCard currentFile={currentFile} modalHeight={modalHeight} />
<Button
size="md"
@@ -100,14 +95,13 @@ const FileDetails: React.FC<FileDetailsProps> = ({
disabled={!hasSelection}
fullWidth
style={{
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
color: 'white'
backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
color: "white",
}}
>
{selectedFiles.length > 1
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
: t('fileManager.openFile', 'Open File')
}
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
: t("fileManager.openFile", "Open File")}
</Button>
</Stack>
);
@@ -1,8 +1,8 @@
import React from 'react';
import { Box, Text, Collapse, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { StirlingFileStub } from '@app/types/fileContext';
import FileListItem from '@app/components/fileManager/FileListItem';
import React from "react";
import { Box, Text, Collapse, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { StirlingFileStub } from "@app/types/fileContext";
import FileListItem from "@app/components/fileManager/FileListItem";
interface FileHistoryGroupProps {
leafFile: StirlingFileStub;
@@ -27,7 +27,7 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
// Sort history files by version number (oldest first, excluding the current leaf file)
const sortedHistory = historyFiles
.filter(file => file.id !== leafFile.id) // Exclude the leaf file itself
.filter((file) => file.id !== leafFile.id) // Exclude the leaf file itself
.sort((a, b) => (b.versionNumber || 1) - (a.versionNumber || 1));
if (!isExpanded || sortedHistory.length === 0) {
@@ -39,7 +39,7 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
<Box ml="md" mt="xs" mb="sm">
<Group align="center" mb="sm">
<Text size="xs" fw={600} c="dimmed">
{t('fileManager.fileHistory', 'File History')} ({sortedHistory.length})
{t("fileManager.fileHistory", "File History")} ({sortedHistory.length})
</Text>
</Group>
@@ -1,23 +1,20 @@
import React, { useMemo, useState } from 'react';
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { detectFileExtension, getFileSize } from '@app/utils/fileUtils';
import { StirlingFileStub } from '@app/types/fileContext';
import ToolChain from '@app/components/shared/ToolChain';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import ShareManagementModal from '@app/components/shared/ShareManagementModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import React, { useMemo, useState } from "react";
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { detectFileExtension, getFileSize } from "@app/utils/fileUtils";
import { StirlingFileStub } from "@app/types/fileContext";
import ToolChain from "@app/components/shared/ToolChain";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
interface FileInfoCardProps {
currentFile: StirlingFileStub | null;
modalHeight: string;
}
const FileInfoCard: React.FC<FileInfoCardProps> = ({
currentFile,
modalHeight
}) => {
const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight }) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const { onMakeCopy } = useFileManagerContext();
@@ -43,38 +40,53 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
const ownerLabel = useMemo(() => {
if (!currentFile) return '';
if (!currentFile) return "";
if (currentFile.remoteOwnerUsername) {
return currentFile.remoteOwnerUsername;
}
return t('fileManager.ownerUnknown', 'Unknown');
return t("fileManager.ownerUnknown", "Unknown");
}, [currentFile, t]);
const lastSyncedLabel = useMemo(() => {
if (!currentFile?.remoteStorageUpdatedAt) return '';
if (!currentFile?.remoteStorageUpdatedAt) return "";
return new Date(currentFile.remoteStorageUpdatedAt).toLocaleString();
}, [currentFile?.remoteStorageUpdatedAt]);
return (
<Card withBorder p={0} mah={`calc(${modalHeight} * 0.45)`} style={{ overflow: 'hidden', flexShrink: 1, display: 'flex', flexDirection: 'column' }}>
<Box bg="gray.4" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)', flexShrink: 0 }}>
<Card
withBorder
p={0}
mah={`calc(${modalHeight} * 0.45)`}
style={{ overflow: "hidden", flexShrink: 1, display: "flex", flexDirection: "column" }}
>
<Box
bg="gray.4"
p="sm"
style={{
borderTopLeftRadius: "var(--mantine-radius-md)",
borderTopRightRadius: "var(--mantine-radius-md)",
flexShrink: 0,
}}
>
<Text size="sm" fw={500} ta="center" c="white">
{t('fileManager.details', 'File Details')}
{t("fileManager.details", "File Details")}
</Text>
</Box>
<ScrollArea style={{ flex: 1, minHeight: 0 }} p="md">
<Stack gap="sm">
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
<PrivateContent>{t('fileManager.fileName', 'Name')}</PrivateContent>
<PrivateContent>{t("fileManager.fileName", "Name")}</PrivateContent>
</Text>
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
{currentFile ? currentFile.name : ''}
<Text size="sm" fw={500} style={{ maxWidth: "60%", textAlign: "right" }} truncate>
{currentFile ? currentFile.name : ""}
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileFormat', 'Format')}</Text>
<Text size="sm" c="dimmed">
{t("fileManager.fileFormat", "Format")}
</Text>
{currentFile ? (
<Badge size="sm" variant="light">
{detectFileExtension(currentFile.name).toUpperCase()}
@@ -86,38 +98,48 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileSize', 'Size')}</Text>
<Text size="sm" c="dimmed">
{t("fileManager.fileSize", "Size")}
</Text>
<Text size="sm" fw={500}>
{currentFile ? getFileSize(currentFile) : ''}
{currentFile ? getFileSize(currentFile) : ""}
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.lastModified', 'Last modified')}</Text>
<Text size="sm" c="dimmed">
{t("fileManager.lastModified", "Last modified")}
</Text>
<Text size="sm" fw={500}>
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ''}
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ""}
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
{currentFile &&
<Badge size="sm" variant="light" color={currentFile?.versionNumber ? 'blue' : 'gray'}>
v{currentFile ? (currentFile.versionNumber || 1) : ''}
</Badge>}
<Text size="sm" c="dimmed">
{t("fileManager.fileVersion", "Version")}
</Text>
{currentFile && (
<Badge size="sm" variant="light" color={currentFile?.versionNumber ? "blue" : "gray"}>
v{currentFile ? currentFile.versionNumber || 1 : ""}
</Badge>
)}
</Group>
{sharingEnabled && isSharedWithYou && (
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.owner', 'Owner')}</Text>
<Text size="sm" c="dimmed">
{t("fileManager.owner", "Owner")}
</Text>
<Group gap="xs">
<Text size="sm" fw={500}>{ownerLabel}</Text>
<Text size="sm" fw={500}>
{ownerLabel}
</Text>
<Badge size="xs" variant="light" color="grape">
{t('fileManager.sharedWithYou', 'Shared with you')}
{t("fileManager.sharedWithYou", "Shared with you")}
</Badge>
</Group>
</Group>
@@ -129,12 +151,10 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<>
<Divider />
<Box py="xs">
<Text size="xs" c="dimmed" mb="xs">{t('fileManager.toolChain', 'Tools Applied')}</Text>
<ToolChain
toolChain={currentFile.toolHistory}
displayStyle="badges"
size="xs"
/>
<Text size="xs" c="dimmed" mb="xs">
{t("fileManager.toolChain", "Tools Applied")}
</Text>
<ToolChain toolChain={currentFile.toolHistory} displayStyle="badges" size="xs" />
</Box>
</>
)}
@@ -142,13 +162,8 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
{currentFile && isSharedWithYou && (
<>
<Divider />
<Button
size="sm"
variant="light"
onClick={() => onMakeCopy(currentFile)}
fullWidth
>
{t('fileManager.makeCopy', 'Make a copy')}
<Button size="sm" variant="light" onClick={() => onMakeCopy(currentFile)} fullWidth>
{t("fileManager.makeCopy", "Make a copy")}
</Button>
</>
)}
@@ -157,39 +172,42 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.cloudFile', 'Cloud file')}</Text>
<Text size="sm" c="dimmed">
{t("fileManager.cloudFile", "Cloud file")}
</Text>
{uploadEnabled && isOutOfSync ? (
<Badge size="xs" variant="light" color="yellow">
{t('fileManager.changesNotUploaded', 'Changes not uploaded')}
{t("fileManager.changesNotUploaded", "Changes not uploaded")}
</Badge>
) : uploadEnabled ? (
<Badge size="xs" variant="light" color="teal">
{t('fileManager.synced', 'Synced')}
{t("fileManager.synced", "Synced")}
</Badge>
) : null}
</Group>
{lastSyncedLabel && (
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.lastSynced', 'Last synced')}</Text>
<Text size="sm" fw={500}>{lastSyncedLabel}</Text>
<Text size="sm" c="dimmed">
{t("fileManager.lastSynced", "Last synced")}
</Text>
<Text size="sm" fw={500}>
{lastSyncedLabel}
</Text>
</Group>
)}
{isSharedByYou && sharingEnabled && (
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.sharing', 'Sharing')}</Text>
<Text size="sm" c="dimmed">
{t("fileManager.sharing", "Sharing")}
</Text>
<Badge size="xs" variant="light" color="blue">
{t('fileManager.sharedByYou', 'Shared by you')}
{t("fileManager.sharedByYou", "Shared by you")}
</Badge>
</Group>
<Button
size="sm"
variant="light"
onClick={() => setShowShareManageModal(true)}
fullWidth
>
{t('storageShare.manage', 'Manage sharing')}
<Button size="sm" variant="light" onClick={() => setShowShareManageModal(true)} fullWidth>
{t("storageShare.manage", "Manage sharing")}
</Button>
</>
)}
@@ -199,9 +217,11 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.storageState', 'Storage')}</Text>
<Text size="sm" c="dimmed">
{t("fileManager.storageState", "Storage")}
</Text>
<Badge size="xs" variant="light" color="gray">
{t('fileManager.localOnly', 'Local only')}
{t("fileManager.localOnly", "Local only")}
</Badge>
</Group>
</>
@@ -1,21 +1,18 @@
import React from 'react';
import { Center, ScrollArea, Text, Stack } from '@mantine/core';
import CloudIcon from '@mui/icons-material/Cloud';
import { useTranslation } from 'react-i18next';
import FileListItem from '@app/components/fileManager/FileListItem';
import FileHistoryGroup from '@app/components/fileManager/FileHistoryGroup';
import EmptyFilesState from '@app/components/fileManager/EmptyFilesState';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import React from "react";
import { Center, ScrollArea, Text, Stack } from "@mantine/core";
import CloudIcon from "@mui/icons-material/Cloud";
import { useTranslation } from "react-i18next";
import FileListItem from "@app/components/fileManager/FileListItem";
import FileHistoryGroup from "@app/components/fileManager/FileHistoryGroup";
import EmptyFilesState from "@app/components/fileManager/EmptyFilesState";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
interface FileListAreaProps {
scrollAreaHeight: string;
scrollAreaStyle?: React.CSSProperties;
}
const FileListArea: React.FC<FileListAreaProps> = ({
scrollAreaHeight,
scrollAreaStyle = {},
}) => {
const FileListArea: React.FC<FileListAreaProps> = ({ scrollAreaHeight, scrollAreaStyle = {} }) => {
const {
activeSource,
recentFiles,
@@ -34,12 +31,12 @@ const FileListArea: React.FC<FileListAreaProps> = ({
} = useFileManagerContext();
const { t } = useTranslation();
if (activeSource === 'recent') {
if (activeSource === "recent") {
return (
<ScrollArea
h={scrollAreaHeight}
style={{
...scrollAreaStyle
...scrollAreaStyle,
}}
type="always"
scrollbarSize={8}
@@ -48,8 +45,10 @@ const FileListArea: React.FC<FileListAreaProps> = ({
{recentFiles.length === 0 && !isLoading ? (
<EmptyFilesState />
) : recentFiles.length === 0 && isLoading ? (
<Center style={{ height: '12.5rem' }}>
<Text c="dimmed" ta="center">{t('fileManager.loadingFiles', 'Loading files...')}</Text>
<Center style={{ height: "12.5rem" }}>
<Text c="dimmed" ta="center">
{t("fileManager.loadingFiles", "Loading files...")}
</Text>
</Center>
) : (
filteredFiles.map((file, index) => {
@@ -93,10 +92,12 @@ const FileListArea: React.FC<FileListAreaProps> = ({
// Google Drive placeholder
return (
<Center style={{ height: '12.5rem' }}>
<Center style={{ height: "12.5rem" }}>
<Stack align="center" gap="sm">
<CloudIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
<Text c="dimmed" ta="center">{t('fileManager.googleDriveNotAvailable', 'Google Drive integration coming soon')}</Text>
<CloudIcon style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }} />
<Text c="dimmed" ta="center">
{t("fileManager.googleDriveNotAvailable", "Google Drive integration coming soon")}
</Text>
</Stack>
</Center>
);
@@ -1,31 +1,31 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DeleteIcon from '@mui/icons-material/Delete';
import DownloadIcon from '@mui/icons-material/Download';
import HistoryIcon from '@mui/icons-material/History';
import RestoreIcon from '@mui/icons-material/Restore';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import CloseIcon from '@mui/icons-material/Close';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CloudDoneIcon from '@mui/icons-material/CloudDone';
import LinkIcon from '@mui/icons-material/Link';
import { useTranslation } from 'react-i18next';
import { getFileSize, getFileDate } from '@app/utils/fileUtils';
import { FileId, StirlingFileStub } from '@app/types/fileContext';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import { zipFileService } from '@app/services/zipFileService';
import ToolChain from '@app/components/shared/ToolChain';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { useFileManagement } from '@app/contexts/FileContext';
import UploadToServerModal from '@app/components/shared/UploadToServerModal';
import ShareFileModal from '@app/components/shared/ShareFileModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import ShareManagementModal from '@app/components/shared/ShareManagementModal';
import apiClient from '@app/services/apiClient';
import { absoluteWithBasePath } from '@app/constants/app';
import { alert } from '@app/components/toast';
import React, { useCallback, useMemo, useState } from "react";
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from "@mantine/core";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import HistoryIcon from "@mui/icons-material/History";
import RestoreIcon from "@mui/icons-material/Restore";
import UnarchiveIcon from "@mui/icons-material/Unarchive";
import CloseIcon from "@mui/icons-material/Close";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import CloudDoneIcon from "@mui/icons-material/CloudDone";
import LinkIcon from "@mui/icons-material/Link";
import { useTranslation } from "react-i18next";
import { getFileSize, getFileDate } from "@app/utils/fileUtils";
import { FileId, StirlingFileStub } from "@app/types/fileContext";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import { zipFileService } from "@app/services/zipFileService";
import ToolChain from "@app/components/shared/ToolChain";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { useFileManagement } from "@app/contexts/FileContext";
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
import ShareFileModal from "@app/components/shared/ShareFileModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
import apiClient from "@app/services/apiClient";
import { absoluteWithBasePath } from "@app/constants/app";
import { alert } from "@app/components/toast";
interface FileListItemProps {
file: StirlingFileStub;
@@ -51,7 +51,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
onDoubleClick,
isHistoryFile = false,
isLatestVersion = false,
isActive = false
isActive = false,
}) => {
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -60,22 +60,22 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [showShareManageModal, setShowShareManageModal] = useState(false);
const { t } = useTranslation();
const { config } = useAppConfig();
const {expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext();
const { expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext();
const { removeFiles } = useFileManagement();
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
// Check file extension
const extLower = (file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || '').toLowerCase();
const isCBZ = extLower === 'cbz';
const isCBR = extLower === 'cbr';
const extLower = (file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || "").toLowerCase();
const isCBZ = extLower === "cbz";
const isCBR = extLower === "cbr";
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
// Get version information for this file
const leafFileId = (isLatestVersion ? file.id : (file.originalFileId || file.id)) as FileId;
const leafFileId = (isLatestVersion ? file.id : file.originalFileId || file.id) as FileId;
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
const currentVersion = file.versionNumber || 1; // Display original files as v1
const isExpanded = expandedFileIds.has(leafFileId);
@@ -83,32 +83,28 @@ const FileListItem: React.FC<FileListItemProps> = ({
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
const isSharedWithYou =
sharingEnabled && (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink);
const isSharedWithYou = sharingEnabled && (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink);
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
const isUploaded = Boolean(file.remoteStorageId);
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal;
const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink;
const accessRole = (isOwnedOrLocal ? 'editor' : (file.remoteAccessRole ?? 'viewer')).toLowerCase();
const hasReadAccess = isOwnedOrLocal || accessRole === 'editor' || accessRole === 'commenter' || accessRole === 'viewer';
const accessRole = (isOwnedOrLocal ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase();
const hasReadAccess = isOwnedOrLocal || accessRole === "editor" || accessRole === "commenter" || accessRole === "viewer";
const canUpload = uploadEnabled && isOwnedOrLocal && isLatestVersion && (!isUploaded || !isUpToDate);
const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion;
const canManageShare = sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId);
const canCopyShareLink =
shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId);
const canCopyShareLink = shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId);
const canDownloadFile = Boolean(onDownload) && hasReadAccess;
const shareBaseUrl = useMemo(() => {
const frontendUrl = (config?.frontendUrl || '').trim();
const frontendUrl = (config?.frontendUrl || "").trim();
if (frontendUrl) {
const normalized = frontendUrl.endsWith('/')
? frontendUrl.slice(0, -1)
: frontendUrl;
const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl;
return `${normalized}/share/`;
}
return absoluteWithBasePath('/share/');
return absoluteWithBasePath("/share/");
}, [config?.frontendUrl]);
const handleCopyShareLink = useCallback(async () => {
@@ -116,14 +112,14 @@ const FileListItem: React.FC<FileListItemProps> = ({
try {
const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>(
`/api/v1/storage/files/${file.remoteStorageId}`,
{ suppressErrorToast: true } as any
{ suppressErrorToast: true } as any,
);
const links = response.data?.shareLinks ?? [];
const token = links[links.length - 1]?.token;
if (!token) {
alert({
alertType: 'warning',
title: t('storageShare.noLinks', 'No active share links yet.'),
alertType: "warning",
title: t("storageShare.noLinks", "No active share links yet."),
expandable: false,
durationMs: 2500,
});
@@ -131,16 +127,16 @@ const FileListItem: React.FC<FileListItemProps> = ({
}
await navigator.clipboard.writeText(`${shareBaseUrl}${token}`);
alert({
alertType: 'success',
title: t('storageShare.copied', 'Link copied to clipboard'),
alertType: "success",
title: t("storageShare.copied", "Link copied to clipboard"),
expandable: false,
durationMs: 2000,
});
} catch (error) {
console.error('Failed to copy share link:', error);
console.error("Failed to copy share link:", error);
alert({
alertType: 'warning',
title: t('storageShare.copyFailed', 'Copy failed'),
alertType: "warning",
title: t("storageShare.copyFailed", "Copy failed"),
expandable: false,
durationMs: 2500,
});
@@ -152,20 +148,22 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Box
p="sm"
style={{
cursor: isHistoryFile || isActive ? 'default' : 'pointer',
cursor: isHistoryFile || isActive ? "default" : "pointer",
backgroundColor: isActive
? 'var(--file-active-bg)'
: isSelected
? 'var(--mantine-color-gray-1)'
: (shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'var(--bg-file-list)'),
? "var(--file-active-bg)"
: isSelected
? "var(--mantine-color-gray-1)"
: shouldShowHovered
? "var(--mantine-color-gray-1)"
: "var(--bg-file-list)",
opacity: isSupported ? 1 : 0.5,
transition: 'background-color 0.15s ease',
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none',
paddingLeft: isHistoryFile ? '2rem' : '0.75rem', // Indent history files
borderLeft: isHistoryFile ? '3px solid var(--mantine-color-blue-4)' : 'none' // Visual indicator for history
transition: "background-color 0.15s ease",
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
paddingLeft: isHistoryFile ? "2rem" : "0.75rem", // Indent history files
borderLeft: isHistoryFile ? "3px solid var(--mantine-color-blue-4)" : "none", // Visual indicator for history
}}
onClick={isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)}
onDoubleClick={onDoubleClick}
@@ -186,8 +184,8 @@ const FileListItem: React.FC<FileListItemProps> = ({
color={isActive ? "green" : undefined}
styles={{
input: {
cursor: isActive ? 'not-allowed' : 'pointer'
}
cursor: isActive ? "not-allowed" : "pointer",
},
}}
/>
</Box>
@@ -203,12 +201,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
size="xs"
variant="light"
style={{
backgroundColor: 'var(--file-active-badge-bg)',
color: 'var(--file-active-badge-fg)',
border: '1px solid var(--file-active-badge-border)'
backgroundColor: "var(--file-active-badge-bg)",
color: "var(--file-active-badge-fg)",
border: "1px solid var(--file-active-badge-border)",
}}
>
{t('fileManager.active', 'Active')}
{t("fileManager.active", "Active")}
</Badge>
)}
<Badge size="xs" variant="light" color={"blue"}>
@@ -216,44 +214,33 @@ const FileListItem: React.FC<FileListItemProps> = ({
</Badge>
{sharingEnabled && isSharedWithYou ? (
<Badge size="xs" variant="light" color="grape">
{t('fileManager.sharedWithYou', 'Shared with you')}
{t("fileManager.sharedWithYou", "Shared with you")}
</Badge>
) : null}
{sharingEnabled && isSharedWithYou && accessRole && accessRole !== 'editor' ? (
{sharingEnabled && isSharedWithYou && accessRole && accessRole !== "editor" ? (
<Badge size="xs" variant="light" color="gray">
{accessRole === 'commenter'
? t('storageShare.roleCommenter', 'Commenter')
: t('storageShare.roleViewer', 'Viewer')}
{accessRole === "commenter"
? t("storageShare.roleCommenter", "Commenter")
: t("storageShare.roleViewer", "Viewer")}
</Badge>
) : isLocalOnly ? (
<Badge size="xs" variant="light" color="gray">
{t('fileManager.localOnly', 'Local only')}
{t("fileManager.localOnly", "Local only")}
</Badge>
) : uploadEnabled && isOutOfSync ? (
<Badge
size="xs"
variant="light"
color="yellow"
leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}
>
{t('fileManager.changesNotUploaded', 'Changes not uploaded')}
<Badge size="xs" variant="light" color="yellow" leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}>
{t("fileManager.changesNotUploaded", "Changes not uploaded")}
</Badge>
) : uploadEnabled && isUploaded ? (
<Badge
size="xs"
variant="light"
color="teal"
leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}
>
{t('fileManager.synced', 'Synced')}
<Badge size="xs" variant="light" color="teal" leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}>
{t("fileManager.synced", "Synced")}
</Badge>
) : null}
{sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && (
<Badge size="xs" variant="light" color="blue">
{t('fileManager.sharedByYou', 'Shared by you')}
{t("fileManager.sharedByYou", "Shared by you")}
</Badge>
)}
</Group>
<Group gap="xs" align="center">
<Text size="xs" c="dimmed">
@@ -262,12 +249,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
{/* Tool chain for processed files */}
{file.toolHistory && file.toolHistory.length > 0 && (
<ToolChain
toolChain={file.toolHistory}
maxWidth={'150px'}
displayStyle="text"
size="xs"
/>
<ToolChain toolChain={file.toolHistory} maxWidth={"150px"} displayStyle="text" size="xs" />
)}
</Group>
</Box>
@@ -288,9 +270,9 @@ const FileListItem: React.FC<FileListItemProps> = ({
onClick={(e) => e.stopPropagation()}
style={{
opacity: shouldShowHovered ? 1 : 0,
transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
transition: 'opacity 0.3s ease, transform 0.3s ease',
pointerEvents: shouldShowHovered ? 'auto' : 'none'
transform: shouldShowHovered ? "scale(1)" : "scale(0.8)",
transition: "opacity 0.3s ease, transform 0.3s ease",
pointerEvents: shouldShowHovered ? "auto" : "none",
}}
>
<MoreVertIcon style={{ fontSize: 20 }} />
@@ -308,7 +290,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
removeFiles([file.id]);
}}
>
{t('fileManager.closeFile', 'Close File')}
{t("fileManager.closeFile", "Close File")}
</Menu.Item>
<Menu.Divider />
</>
@@ -322,7 +304,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
onDownload?.();
}}
>
{t('fileManager.download', 'Download')}
{t("fileManager.download", "Download")}
</Menu.Item>
)}
@@ -335,8 +317,8 @@ const FileListItem: React.FC<FileListItemProps> = ({
}}
>
{isUploaded
? t('fileManager.updateOnServer', 'Update on Server')
: t('fileManager.uploadToServer', 'Upload to Server')}
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server")}
</Menu.Item>
)}
@@ -348,7 +330,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
setShowShareModal(true);
}}
>
{t('fileManager.share', 'Share')}
{t("fileManager.share", "Share")}
</Menu.Item>
)}
@@ -360,7 +342,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
void handleCopyShareLink();
}}
>
{t('storageShare.copyLink', 'Copy share link')}
{t("storageShare.copyLink", "Copy share link")}
</Menu.Item>
)}
@@ -372,7 +354,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
setShowShareManageModal(true);
}}
>
{t('storageShare.manage', 'Manage sharing')}
{t("storageShare.manage", "Manage sharing")}
</Menu.Item>
)}
@@ -380,20 +362,13 @@ const FileListItem: React.FC<FileListItemProps> = ({
{isLatestVersion && hasVersionHistory && (
<>
<Menu.Item
leftSection={
<HistoryIcon style={{ fontSize: 16 }} />
}
leftSection={<HistoryIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onToggleExpansion(leafFileId);
}}
>
{
(isExpanded ?
t('fileManager.hideHistory', 'Hide History') :
t('fileManager.showHistory', 'Show History')
)
}
{isExpanded ? t("fileManager.hideHistory", "Hide History") : t("fileManager.showHistory", "Show History")}
</Menu.Item>
<Menu.Divider />
</>
@@ -408,7 +383,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
e.stopPropagation();
}}
>
{t('fileManager.restore', 'Restore')}
{t("fileManager.restore", "Restore")}
</Menu.Item>
<Menu.Divider />
</>
@@ -424,7 +399,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
onUnzipFile(file);
}}
>
{t('fileManager.unzip', 'Unzip')}
{t("fileManager.unzip", "Unzip")}
</Menu.Item>
<Menu.Divider />
</>
@@ -437,14 +412,13 @@ const FileListItem: React.FC<FileListItemProps> = ({
onRemove();
}}
>
{t('fileManager.delete', 'Delete')}
{t("fileManager.delete", "Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Box>
{ <Divider color="var(--mantine-color-gray-3)" />}
{<Divider color="var(--mantine-color-gray-3)" />}
{canUpload && (
<UploadToServerModal
opened={showUploadModal}
@@ -462,11 +436,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
/>
)}
{canManageShare && (
<ShareManagementModal
opened={showShareManageModal}
onClose={() => setShowShareManageModal(false)}
file={file}
/>
<ShareManagementModal opened={showShareManageModal} onClose={() => setShowShareManageModal(false)} file={file} />
)}
</>
);
@@ -1,15 +1,15 @@
import React, { useState } from 'react';
import { Stack, Text, Button, Group } from '@mantine/core';
import HistoryIcon from '@mui/icons-material/History';
import PhonelinkIcon from '@mui/icons-material/Phonelink';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useIsMobile } from '@app/hooks/useIsMobile';
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
import React, { useState } from "react";
import { Stack, Text, Button, Group } from "@mantine/core";
import HistoryIcon from "@mui/icons-material/History";
import PhonelinkIcon from "@mui/icons-material/Phonelink";
import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import { useGoogleDrivePicker } from "@app/hooks/useGoogleDrivePicker";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
interface FileSourceButtonsProps {
horizontal?: boolean;
@@ -24,17 +24,15 @@ const GoogleDriveIcon: React.FC<{ disabled?: boolean }> = ({ disabled }) => (
src="/images/google-drive.svg"
alt="Google Drive"
style={{
width: '20px',
height: '20px',
width: "20px",
height: "20px",
opacity: disabled ? 0.5 : 1,
filter: disabled ? 'grayscale(100%)' : 'none',
filter: disabled ? "grayscale(100%)" : "none",
}}
/>
);
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false
}) => {
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = false }) => {
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext();
const { t } = useTranslation();
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
@@ -53,7 +51,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
onGoogleDriveSelect(files);
}
} catch (error) {
console.error('Failed to pick files from Google Drive:', error);
console.error("Failed to pick files from Google Drive:", error);
}
};
@@ -74,18 +72,18 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
const shouldHideMobileQR = !isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner;
const buttonProps = {
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
variant: (source: string) => (activeSource === source ? "filled" : "subtle"),
getColor: (source: string) => (activeSource === source ? "var(--mantine-color-gray-2)" : undefined),
getStyles: (source: string) => ({
root: {
backgroundColor: activeSource === source ? undefined : 'transparent',
color: activeSource === source ? 'var(--mantine-color-gray-9)' : 'var(--mantine-color-gray-6)',
border: 'none',
'&:hover': {
backgroundColor: activeSource === source ? undefined : 'var(--mantine-color-gray-0)'
}
}
})
backgroundColor: activeSource === source ? undefined : "transparent",
color: activeSource === source ? "var(--mantine-color-gray-9)" : "var(--mantine-color-gray-6)",
border: "none",
"&:hover": {
backgroundColor: activeSource === source ? undefined : "var(--mantine-color-gray-0)",
},
},
}),
};
const buttons = (
@@ -93,18 +91,18 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
<Button
leftSection={<HistoryIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={() => onSourceChange('recent')}
onClick={() => onSourceChange("recent")}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
color={buttonProps.getColor('recent')}
styles={buttonProps.getStyles('recent')}
color={buttonProps.getColor("recent")}
styles={buttonProps.getStyles("recent")}
>
{horizontal ? t('fileManager.recent', 'Recent') : t('fileManager.recent', 'Recent')}
{horizontal ? t("fileManager.recent", "Recent") : t("fileManager.recent", "Recent")}
</Button>
<Button
variant="subtle"
color='var(--mantine-color-gray-6)'
color="var(--mantine-color-gray-6)"
leftSection={<UploadIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={onLocalFileClick}
@@ -112,12 +110,12 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
size={horizontal ? "xs" : "sm"}
styles={{
root: {
backgroundColor: 'transparent',
border: 'none',
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)'
}
}
backgroundColor: "transparent",
border: "none",
"&:hover": {
backgroundColor: "var(--mantine-color-gray-0)",
},
},
}}
>
{horizontal ? terminology.upload : terminology.uploadFiles}
@@ -126,7 +124,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
{!shouldHideGoogleDrive && (
<Button
variant="subtle"
color='var(--mantine-color-gray-6)'
color="var(--mantine-color-gray-6)"
leftSection={<GoogleDriveIcon disabled={!isGoogleDriveEnabled} />}
justify={horizontal ? "center" : "flex-start"}
onClick={handleGoogleDriveClick}
@@ -135,23 +133,27 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
disabled={!isGoogleDriveEnabled}
styles={{
root: {
backgroundColor: 'transparent',
border: 'none',
'&:hover': {
backgroundColor: isGoogleDriveEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
}
}
backgroundColor: "transparent",
border: "none",
"&:hover": {
backgroundColor: isGoogleDriveEnabled ? "var(--mantine-color-gray-0)" : "transparent",
},
},
}}
title={!isGoogleDriveEnabled ? t('fileManager.googleDriveNotAvailable', 'Google Drive integration not available') : undefined}
title={
!isGoogleDriveEnabled
? t("fileManager.googleDriveNotAvailable", "Google Drive integration not available")
: undefined
}
>
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
{horizontal ? t("fileManager.googleDriveShort", "Drive") : t("fileManager.googleDrive", "Google Drive")}
</Button>
)}
{!shouldHideMobileQR && (
<Button
variant="subtle"
color='var(--mantine-color-gray-6)'
color="var(--mantine-color-gray-6)"
leftSection={<PhonelinkIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={handleMobileUploadClick}
@@ -160,16 +162,16 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
disabled={!isMobileUploadEnabled}
styles={{
root: {
backgroundColor: 'transparent',
border: 'none',
'&:hover': {
backgroundColor: isMobileUploadEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
}
}
backgroundColor: "transparent",
border: "none",
"&:hover": {
backgroundColor: isMobileUploadEnabled ? "var(--mantine-color-gray-0)" : "transparent",
},
},
}}
title={!isMobileUploadEnabled ? t('fileManager.mobileUploadNotAvailable', 'Mobile upload not available') : undefined}
title={!isMobileUploadEnabled ? t("fileManager.mobileUploadNotAvailable", "Mobile upload not available") : undefined}
>
{horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')}
{horizontal ? t("fileManager.mobileShort", "Mobile") : t("fileManager.mobileUpload", "Mobile Upload")}
</Button>
)}
</>
@@ -178,7 +180,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
if (horizontal) {
return (
<>
<Group gap="xs" justify="center" style={{ width: '100%' }}>
<Group gap="xs" justify="center" style={{ width: "100%" }}>
{buttons}
</Group>
<MobileUploadModal
@@ -192,9 +194,9 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
return (
<>
<Stack gap="xs" style={{ height: '100%' }}>
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
{t('fileManager.myFiles', 'My Files')}
<Stack gap="xs" style={{ height: "100%" }}>
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: "1rem" }}>
{t("fileManager.myFiles", "My Files")}
</Text>
{buttons}
</Stack>
@@ -1,5 +1,5 @@
import React from 'react';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import React from "react";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
const HiddenFileInput: React.FC = () => {
const { fileInputRef, onFileInputChange } = useFileManagerContext();
@@ -10,7 +10,7 @@ const HiddenFileInput: React.FC = () => {
type="file"
multiple={true}
onChange={onFileInputChange}
style={{ display: 'none' }}
style={{ display: "none" }}
data-testid="file-input"
/>
);
@@ -1,19 +1,15 @@
import React from 'react';
import { Box } from '@mantine/core';
import FileSourceButtons from '@app/components/fileManager/FileSourceButtons';
import FileDetails from '@app/components/fileManager/FileDetails';
import SearchInput from '@app/components/fileManager/SearchInput';
import FileListArea from '@app/components/fileManager/FileListArea';
import FileActions from '@app/components/fileManager/FileActions';
import HiddenFileInput from '@app/components/fileManager/HiddenFileInput';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import React from "react";
import { Box } from "@mantine/core";
import FileSourceButtons from "@app/components/fileManager/FileSourceButtons";
import FileDetails from "@app/components/fileManager/FileDetails";
import SearchInput from "@app/components/fileManager/SearchInput";
import FileListArea from "@app/components/fileManager/FileListArea";
import FileActions from "@app/components/fileManager/FileActions";
import HiddenFileInput from "@app/components/fileManager/HiddenFileInput";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
const MobileLayout: React.FC = () => {
const {
activeSource,
selectedFiles,
modalHeight,
} = useFileManagerContext();
const { activeSource, selectedFiles, modalHeight } = useFileManagerContext();
// Calculate the height more accurately based on actual content
const calculateFileListHeight = () => {
@@ -21,17 +17,17 @@ const MobileLayout: React.FC = () => {
const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding
// Estimate heights of fixed components
const fileSourceHeight = '3rem'; // FileSourceButtons height
const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height
const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom)
const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height
const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps
const fileSourceHeight = "3rem"; // FileSourceButtons height
const fileDetailsHeight = selectedFiles.length > 0 ? "10rem" : "8rem"; // FileDetails compact height
const fileActionsHeight = activeSource === "recent" ? "3rem" : "0rem"; // FileActions height (now at bottom)
const searchHeight = activeSource === "recent" ? "3rem" : "0rem"; // SearchInput height
const gapHeight = activeSource === "recent" ? "3.75rem" : "2rem"; // Stack gaps
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`;
};
return (
<Box h="100%" p="sm" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<Box h="100%" p="sm" style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{/* Section 1: File Sources - Fixed at top */}
<Box style={{ flexShrink: 0 }}>
<FileSourceButtons horizontal={true} />
@@ -42,28 +38,34 @@ const MobileLayout: React.FC = () => {
</Box>
{/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
<Box style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--bg-file-list)',
borderRadius: '0.5rem',
border: '1px solid var(--mantine-color-gray-2)',
overflow: 'hidden',
minHeight: 0
}}>
{activeSource === 'recent' && (
<Box
style={{
flex: 1,
display: "flex",
flexDirection: "column",
backgroundColor: "var(--bg-file-list)",
borderRadius: "0.5rem",
border: "1px solid var(--mantine-color-gray-2)",
overflow: "hidden",
minHeight: 0,
}}
>
{activeSource === "recent" && (
<>
<Box style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)'
}}>
<Box
style={{
flexShrink: 0,
borderBottom: "1px solid var(--mantine-color-gray-2)",
}}
>
<SearchInput />
</Box>
<Box style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)'
}}>
<Box
style={{
flexShrink: 0,
borderBottom: "1px solid var(--mantine-color-gray-2)",
}}
>
<FileActions />
</Box>
</>
@@ -74,11 +76,11 @@ const MobileLayout: React.FC = () => {
scrollAreaHeight={calculateFileListHeight()}
scrollAreaStyle={{
height: calculateFileListHeight(),
maxHeight: '60vh',
minHeight: '9.375rem',
backgroundColor: 'transparent',
border: 'none',
borderRadius: 0
maxHeight: "60vh",
minHeight: "9.375rem",
backgroundColor: "transparent",
border: "none",
borderRadius: 0,
}}
/>
</Box>
@@ -1,8 +1,8 @@
import React from 'react';
import { TextInput } from '@mantine/core';
import SearchIcon from '@mui/icons-material/Search';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import React from "react";
import { TextInput } from "@mantine/core";
import SearchIcon from "@mui/icons-material/Search";
import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
interface SearchInputProps {
style?: React.CSSProperties;
@@ -14,20 +14,19 @@ const SearchInput: React.FC<SearchInputProps> = ({ style }) => {
return (
<TextInput
placeholder={t('fileManager.searchFiles', 'Search files...')}
placeholder={t("fileManager.searchFiles", "Search files...")}
leftSection={<SearchIcon />}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
style={{ padding: '0.5rem', ...style }}
style={{ padding: "0.5rem", ...style }}
styles={{
input: {
border: 'none',
backgroundColor: 'transparent'
}
border: "none",
backgroundColor: "transparent",
},
}}
/>
);
};
export default SearchInput;
export default SearchInput;
@@ -1,29 +1,29 @@
import React from 'react';
import { HotkeyBinding } from '@app/utils/hotkeys';
import { useHotkeys } from '@app/contexts/HotkeyContext';
import React from "react";
import { HotkeyBinding } from "@app/utils/hotkeys";
import { useHotkeys } from "@app/contexts/HotkeyContext";
interface HotkeyDisplayProps {
binding: HotkeyBinding | null | undefined;
size?: 'sm' | 'md';
size?: "sm" | "md";
muted?: boolean;
}
const baseKeyStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '0.375rem',
background: 'var(--mantine-color-gray-1)',
border: '1px solid var(--mantine-color-gray-3)',
padding: '0.125rem 0.35rem',
fontSize: '0.75rem',
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.375rem",
background: "var(--mantine-color-gray-1)",
border: "1px solid var(--mantine-color-gray-3)",
padding: "0.125rem 0.35rem",
fontSize: "0.75rem",
lineHeight: 1,
fontFamily: 'var(--mantine-font-family-monospace, monospace)',
minWidth: '1.35rem',
color: 'var(--mantine-color-text)',
fontFamily: "var(--mantine-font-family-monospace, monospace)",
minWidth: "1.35rem",
color: "var(--mantine-color-text)",
};
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 'sm', muted = false }) => {
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = "sm", muted = false }) => {
const { getDisplayParts } = useHotkeys();
const parts = getDisplayParts(binding);
@@ -31,24 +31,26 @@ export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 's
return null;
}
const keyStyle = size === 'md'
? { ...baseKeyStyle, fontSize: '0.85rem', padding: '0.2rem 0.5rem' }
: baseKeyStyle;
const keyStyle = size === "md" ? { ...baseKeyStyle, fontSize: "0.85rem", padding: "0.2rem 0.5rem" } : baseKeyStyle;
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
color: muted ? 'var(--mantine-color-dimmed)' : 'inherit',
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
color: muted ? "var(--mantine-color-dimmed)" : "inherit",
fontWeight: muted ? 500 : 600,
}}
>
{parts.map((part, index) => (
<React.Fragment key={`${part}-${index}`}>
<kbd style={keyStyle}>{part}</kbd>
{index < parts.length - 1 && <span aria-hidden style={{ fontWeight: 400 }}>+</span>}
{index < parts.length - 1 && (
<span aria-hidden style={{ fontWeight: 400 }}>
+
</span>
)}
</React.Fragment>
))}
</span>
@@ -1,24 +1,24 @@
import { useCallback } from 'react';
import { Box } from '@mantine/core';
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFileState, useFileActions } from '@app/contexts/FileContext';
import { useNavigationState, useNavigationActions, useNavigationGuard } from '@app/contexts/NavigationContext';
import { isBaseWorkbench } from '@app/types/workbench';
import { useViewer } from '@app/contexts/ViewerContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { FileId } from '@app/types/file';
import styles from '@app/components/layout/Workbench.module.css';
import { useCallback } from "react";
import { Box } from "@mantine/core";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { useNavigationState, useNavigationActions, useNavigationGuard } from "@app/contexts/NavigationContext";
import { isBaseWorkbench } from "@app/types/workbench";
import { useViewer } from "@app/contexts/ViewerContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { FileId } from "@app/types/file";
import styles from "@app/components/layout/Workbench.module.css";
import TopControls from '@app/components/shared/TopControls';
import FileEditor from '@app/components/fileEditor/FileEditor';
import PageEditor from '@app/components/pageEditor/PageEditor';
import PageEditorControls from '@app/components/pageEditor/PageEditorControls';
import Viewer from '@app/components/viewer/Viewer';
import LandingPage from '@app/components/shared/LandingPage';
import Footer from '@app/components/shared/Footer';
import DismissAllErrorsButton from '@app/components/shared/DismissAllErrorsButton';
import TopControls from "@app/components/shared/TopControls";
import FileEditor from "@app/components/fileEditor/FileEditor";
import PageEditor from "@app/components/pageEditor/PageEditor";
import PageEditorControls from "@app/components/pageEditor/PageEditorControls";
import Viewer from "@app/components/viewer/Viewer";
import LandingPage from "@app/components/shared/LandingPage";
import Footer from "@app/components/shared/Footer";
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
// No props needed - component uses contexts directly
export default function Workbench() {
@@ -54,41 +54,47 @@ export default function Workbench() {
// Get active file index from ViewerContext
const { activeFileIndex, setActiveFileIndex } = useViewer();
// Get navigation guard for unsaved changes check when switching files
const { requestNavigation } = useNavigationGuard();
// Wrap file selection to check for unsaved changes before switching
// requestNavigation will show the modal if there are unsaved changes, otherwise navigate immediately
const handleFileSelect = useCallback((index: number) => {
// Don't do anything if selecting the same file
if (index === activeFileIndex) return;
const handleFileSelect = useCallback(
(index: number) => {
// Don't do anything if selecting the same file
if (index === activeFileIndex) return;
// requestNavigation handles the unsaved changes check internally
requestNavigation(() => {
setActiveFileIndex(index);
});
}, [activeFileIndex, requestNavigation, setActiveFileIndex]);
// requestNavigation handles the unsaved changes check internally
requestNavigation(() => {
setActiveFileIndex(index);
});
},
[activeFileIndex, requestNavigation, setActiveFileIndex],
);
const handleFileRemove = useCallback(async (fileId: FileId) => {
await fileActions.removeFiles([fileId], false); // false = don't delete from IndexedDB, just remove from context
}, [fileActions]);
const handleFileRemove = useCallback(
async (fileId: FileId) => {
await fileActions.removeFiles([fileId], false); // false = don't delete from IndexedDB, just remove from context
},
[fileActions],
);
const handlePreviewClose = () => {
setPreviewFile(null);
const previousMode = sessionStorage.getItem('previousMode');
if (previousMode === 'split') {
const previousMode = sessionStorage.getItem("previousMode");
if (previousMode === "split") {
// Use context's handleToolSelect which coordinates tool selection and view changes
handleToolSelect('split');
sessionStorage.removeItem('previousMode');
} else if (previousMode === 'compress') {
handleToolSelect('compress');
sessionStorage.removeItem('previousMode');
} else if (previousMode === 'convert') {
handleToolSelect('convert');
sessionStorage.removeItem('previousMode');
handleToolSelect("split");
sessionStorage.removeItem("previousMode");
} else if (previousMode === "compress") {
handleToolSelect("compress");
sessionStorage.removeItem("previousMode");
} else if (previousMode === "convert") {
handleToolSelect("convert");
sessionStorage.removeItem("previousMode");
} else {
setCurrentView('fileEditor');
setCurrentView("fileEditor");
}
};
@@ -104,15 +110,11 @@ export default function Workbench() {
}
if (activeFiles.length === 0) {
return (
<LandingPage
/>
);
return <LandingPage />;
}
switch (currentView) {
case "fileEditor":
return (
<FileEditor
toolMode={!!selectedToolId}
@@ -124,13 +126,12 @@ export default function Workbench() {
onMergeFiles: (filesToMerge) => {
addFiles(filesToMerge);
setCurrentView("viewer");
}
},
})}
/>
);
case "viewer":
return (
<Viewer
sidebarsVisible={sidebarsVisible}
@@ -143,34 +144,31 @@ export default function Workbench() {
);
case "pageEditor":
return (
<div style={{ position: 'relative', flex: '1 1 0', height: 0 }}>
<PageEditor
onFunctionsReady={setPageEditorFunctions}
/>
<div style={{ position: "relative", flex: "1 1 0", height: 0 }}>
<PageEditor onFunctionsReady={setPageEditorFunctions} />
{pageEditorFunctions && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 100 }}>
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, zIndex: 100 }}>
<PageEditorControls
onClosePdf={pageEditorFunctions.closePdf}
onUndo={pageEditorFunctions.handleUndo}
onRedo={pageEditorFunctions.handleRedo}
canUndo={pageEditorFunctions.canUndo}
canRedo={pageEditorFunctions.canRedo}
onRotate={pageEditorFunctions.handleRotate}
onDelete={pageEditorFunctions.handleDelete}
onSplit={pageEditorFunctions.handleSplit}
onSplitAll={pageEditorFunctions.handleSplitAll}
onPageBreak={pageEditorFunctions.handlePageBreak}
onPageBreakAll={pageEditorFunctions.handlePageBreakAll}
onExportAll={pageEditorFunctions.onExportAll}
exportLoading={pageEditorFunctions.exportLoading}
selectionMode={pageEditorFunctions.selectionMode}
selectedPageIds={pageEditorFunctions.selectedPageIds}
displayDocument={pageEditorFunctions.displayDocument}
splitPositions={pageEditorFunctions.splitPositions}
totalPages={pageEditorFunctions.totalPages}
/>
onClosePdf={pageEditorFunctions.closePdf}
onUndo={pageEditorFunctions.handleUndo}
onRedo={pageEditorFunctions.handleRedo}
canUndo={pageEditorFunctions.canUndo}
canRedo={pageEditorFunctions.canRedo}
onRotate={pageEditorFunctions.handleRotate}
onDelete={pageEditorFunctions.handleDelete}
onSplit={pageEditorFunctions.handleSplit}
onSplitAll={pageEditorFunctions.handleSplitAll}
onPageBreak={pageEditorFunctions.handlePageBreak}
onPageBreakAll={pageEditorFunctions.handlePageBreakAll}
onExportAll={pageEditorFunctions.onExportAll}
exportLoading={pageEditorFunctions.exportLoading}
selectionMode={pageEditorFunctions.selectionMode}
selectedPageIds={pageEditorFunctions.selectedPageIds}
displayDocument={pageEditorFunctions.displayDocument}
splitPositions={pageEditorFunctions.splitPositions}
totalPages={pageEditorFunctions.totalPages}
/>
</div>
)}
</div>
@@ -188,16 +186,16 @@ export default function Workbench() {
style={
isRainbowMode
? {} // No background color in rainbow mode
: { backgroundColor: 'var(--bg-background)' }
: { backgroundColor: "var(--bg-background)" }
}
>
{/* Top Controls */}
{activeFiles.length > 0 && !customWorkbenchViews.find(v => v.workbenchId === currentView)?.hideTopControls && (
{activeFiles.length > 0 && !customWorkbenchViews.find((v) => v.workbenchId === currentView)?.hideTopControls && (
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
customViews={customWorkbenchViews}
activeFiles={activeFiles.map(f => {
activeFiles={activeFiles.map((f) => {
const stub = selectors.getStirlingFileStub(f.fileId);
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
})}
@@ -212,10 +210,10 @@ export default function Workbench() {
{/* Main content area */}
<Box
className={`flex-1 min-h-0 z-10 ${currentView === 'pageEditor' ? 'relative flex flex-col' : `relative ${styles.workbenchScrollable}`}`}
className={`flex-1 min-h-0 z-10 ${currentView === "pageEditor" ? "relative flex flex-col" : `relative ${styles.workbenchScrollable}`}`}
style={{
transition: 'opacity 0.15s ease-in-out',
...(currentView === 'pageEditor' && { height: 0 }),
transition: "opacity 0.15s ease-in-out",
...(currentView === "pageEditor" && { height: 0 }),
}}
>
{renderMainContent()}
@@ -118,7 +118,6 @@
}
}
.heroIconsContainer {
display: flex;
gap: 32px;
@@ -141,7 +140,9 @@
border: none;
padding: 0;
cursor: pointer;
transition: transform 0.2s ease, opacity 0.2s ease;
transition:
transform 0.2s ease,
opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
@@ -181,7 +182,14 @@
}
.iconLabel {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
font-family:
"Inter",
system-ui,
-apple-system,
"Segoe UI",
Roboto,
Arial,
sans-serif;
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
@@ -266,7 +274,7 @@
opacity: 1;
border: 1px solid rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.9);
color: #1F2933;
color: #1f2933;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.7);
}
@@ -282,7 +290,14 @@
/* Title styles */
.titleText {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
font-family:
"Inter",
system-ui,
-apple-system,
"Segoe UI",
Roboto,
Arial,
sans-serif;
font-weight: 600;
font-size: 22px;
color: var(--onboarding-title);
@@ -290,7 +305,14 @@
/* Body text styles */
.bodyText {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
font-family:
"Inter",
system-ui,
-apple-system,
"Segoe UI",
Roboto,
Arial,
sans-serif;
font-size: 16px;
color: var(--onboarding-body);
line-height: 1.5;
@@ -314,8 +336,8 @@
}
.v2Badge {
background: #DBEFFF;
color: #2A4BFF;
background: #dbefff;
color: #2a4bff;
padding: 4px 12px;
border-radius: 6px;
font-size: 14px;
@@ -1,10 +1,10 @@
import React from 'react';
import { Button, Group, ActionIcon } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import { useTranslation } from 'react-i18next';
import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onboardingFlowConfig';
import type { LicenseNotice } from '@app/types/types';
import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
import React from "react";
import { Button, Group, ActionIcon } from "@mantine/core";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import { useTranslation } from "react-i18next";
import { ButtonDefinition, type FlowState } from "@app/components/onboarding/onboardingFlowConfig";
import type { LicenseNotice } from "@app/types/types";
import type { ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
interface SlideButtonsProps {
slideDefinition: {
@@ -18,49 +18,49 @@ interface SlideButtonsProps {
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
const { t } = useTranslation();
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left");
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right");
const buttonStyles = (variant: ButtonDefinition['variant']) =>
variant === 'primary'
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
variant === "primary"
? {
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
background: "var(--onboarding-primary-button-bg)",
color: "var(--onboarding-primary-button-text)",
},
}
: {
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
background: "var(--onboarding-secondary-button-bg)",
border: "1px solid var(--onboarding-secondary-button-border)",
color: "var(--onboarding-secondary-button-text)",
},
};
const resolveButtonLabel = (button: ButtonDefinition) => {
// Special case: override "See Plans" with "Upgrade now" when over limit
if (
button.type === 'button' &&
slideDefinition.id === 'server-license' &&
button.action === 'see-plans' &&
button.type === "button" &&
slideDefinition.id === "server-license" &&
button.action === "see-plans" &&
licenseNotice.isOverLimit
) {
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
return t("onboarding.serverLicense.upgrade", "Upgrade now →");
}
// Translate the label (it's a translation key)
const label = button.label ?? '';
if (!label) return '';
const label = button.label ?? "";
if (!label) return "";
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
const fallback = label.split('.').pop() || label;
const fallback = label.split(".").pop() || label;
return t(label, fallback);
};
const renderButton = (button: ButtonDefinition) => {
const disabled = button.disabledWhen?.(flowState) ?? false;
if (button.type === 'icon') {
if (button.type === "icon") {
return (
<ActionIcon
key={button.key}
@@ -70,18 +70,18 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
disabled={disabled}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
background: "var(--onboarding-secondary-button-bg)",
border: "1px solid var(--onboarding-secondary-button-border)",
color: "var(--onboarding-secondary-button-text)",
},
}}
>
{button.icon === 'chevron-left' && <ChevronLeftIcon fontSize="small" />}
{button.icon === "chevron-left" && <ChevronLeftIcon fontSize="small" />}
</ActionIcon>
);
}
const variant = button.variant ?? 'secondary';
const variant = button.variant ?? "secondary";
const label = resolveButtonLabel(button);
return (
@@ -1,33 +1,30 @@
import { useEffect, useMemo, useCallback, useState } from 'react';
import { type StepType } from '@reactour/tour';
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
import { isAuthRoute } from '@app/constants/routes';
import { dispatchTourState } from '@app/constants/events';
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
import {
useServerLicenseRequest,
useTourRequest,
} from '@app/components/onboarding/useOnboardingEffects';
import { useOnboardingDownload } from '@app/components/onboarding/useOnboardingDownload';
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
import { createWhatsNewStepsConfig } from '@app/components/onboarding/whatsNewStepsConfig';
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useServerExperience } from '@app/hooks/useServerExperience';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import apiClient from '@app/services/apiClient';
import '@app/components/onboarding/OnboardingTour.css';
import { useAccountLogout } from '@app/extensions/accountLogout';
import { useAuth } from '@app/auth/UseSession';
import { useEffect, useMemo, useCallback, useState } from "react";
import { type StepType } from "@reactour/tour";
import { useTranslation } from "react-i18next";
import { useNavigate, useLocation } from "react-router-dom";
import { isAuthRoute } from "@app/constants/routes";
import { dispatchTourState } from "@app/constants/events";
import { useOnboardingOrchestrator } from "@app/components/onboarding/orchestrator/useOnboardingOrchestrator";
import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding";
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from "@app/components/onboarding/OnboardingTour";
import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide";
import { useServerLicenseRequest, useTourRequest } from "@app/components/onboarding/useOnboardingEffects";
import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload";
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext";
import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext";
import { createUserStepsConfig } from "@app/components/onboarding/userStepsConfig";
import { createAdminStepsConfig } from "@app/components/onboarding/adminStepsConfig";
import { createWhatsNewStepsConfig } from "@app/components/onboarding/whatsNewStepsConfig";
import { removeAllGlows } from "@app/components/onboarding/tourGlow";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { useServerExperience } from "@app/hooks/useServerExperience";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import apiClient from "@app/services/apiClient";
import "@app/components/onboarding/OnboardingTour.css";
import { useAccountLogout } from "@app/extensions/accountLogout";
import { useAuth } from "@app/auth/UseSession";
export default function Onboarding() {
const { t } = useTranslation();
@@ -52,13 +49,16 @@ export default function Onboarding() {
const accountLogout = useAccountLogout();
const { signOut } = useAuth();
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
actions.updateRuntimeState({ selectedRole: role });
serverExperience.setSelfReportedAdmin(role === 'admin');
}, [actions, serverExperience]);
const handleRoleSelect = useCallback(
(role: "admin" | "user" | null) => {
actions.updateRuntimeState({ selectedRole: role });
serverExperience.setSelfReportedAdmin(role === "admin");
},
[actions, serverExperience],
);
const redirectToLogin = useCallback(() => {
window.location.assign('/login');
window.location.assign("/login");
}, []);
const handlePasswordChanged = useCallback(async () => {
@@ -80,84 +80,97 @@ export default function Onboarding() {
}
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]);
const handleAnalyticsChoice = useCallback(async (enableAnalytics: boolean) => {
if (analyticsLoading) return;
setAnalyticsLoading(true);
setAnalyticsError(null);
const handleAnalyticsChoice = useCallback(
async (enableAnalytics: boolean) => {
if (analyticsLoading) return;
setAnalyticsLoading(true);
setAnalyticsError(null);
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
const formData = new FormData();
formData.append("enabled", enableAnalytics.toString());
try {
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
await refetchConfig();
setShowAnalyticsModal(false);
setAnalyticsModalDismissed(true);
} catch (error) {
setAnalyticsError(error instanceof Error ? error.message : 'Unknown error');
} finally {
setAnalyticsLoading(false);
}
}, [analyticsLoading, refetchConfig]);
const handleButtonAction = useCallback(async (action: ButtonAction) => {
switch (action) {
case 'next':
case 'complete-close':
actions.complete();
break;
case 'prev':
actions.prev();
break;
case 'close':
actions.skip();
break;
case 'download-selected':
handleDownloadSelected();
actions.complete();
break;
case 'security-next':
if (!runtimeState.selectedRole) return;
if (runtimeState.selectedRole !== 'admin') {
actions.updateRuntimeState({ tourType: 'whatsnew' });
setIsTourOpen(true);
}
actions.complete();
break;
case 'launch-admin':
actions.updateRuntimeState({ tourType: 'admin' });
setIsTourOpen(true);
break;
case 'launch-tools':
actions.updateRuntimeState({ tourType: 'whatsnew' });
setIsTourOpen(true);
break;
case 'launch-auto': {
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'whatsnew';
actions.updateRuntimeState({ tourType });
setIsTourOpen(true);
break;
try {
await apiClient.post("/api/v1/settings/update-enable-analytics", formData);
await refetchConfig();
setShowAnalyticsModal(false);
setAnalyticsModalDismissed(true);
} catch (error) {
setAnalyticsError(error instanceof Error ? error.message : "Unknown error");
} finally {
setAnalyticsLoading(false);
}
case 'skip-to-license':
actions.complete();
break;
case 'skip-tour':
actions.complete();
break;
case 'see-plans':
actions.complete();
navigate('/settings/adminPlan');
break;
case 'enable-analytics':
await handleAnalyticsChoice(true);
break;
case 'disable-analytics':
await handleAnalyticsChoice(false);
break;
}
}, [actions, handleAnalyticsChoice, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
},
[analyticsLoading, refetchConfig],
);
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
const handleButtonAction = useCallback(
async (action: ButtonAction) => {
switch (action) {
case "next":
case "complete-close":
actions.complete();
break;
case "prev":
actions.prev();
break;
case "close":
actions.skip();
break;
case "download-selected":
handleDownloadSelected();
actions.complete();
break;
case "security-next":
if (!runtimeState.selectedRole) return;
if (runtimeState.selectedRole !== "admin") {
actions.updateRuntimeState({ tourType: "whatsnew" });
setIsTourOpen(true);
}
actions.complete();
break;
case "launch-admin":
actions.updateRuntimeState({ tourType: "admin" });
setIsTourOpen(true);
break;
case "launch-tools":
actions.updateRuntimeState({ tourType: "whatsnew" });
setIsTourOpen(true);
break;
case "launch-auto": {
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === "admin" ? "admin" : "whatsnew";
actions.updateRuntimeState({ tourType });
setIsTourOpen(true);
break;
}
case "skip-to-license":
actions.complete();
break;
case "skip-tour":
actions.complete();
break;
case "see-plans":
actions.complete();
navigate("/settings/adminPlan");
break;
case "enable-analytics":
await handleAnalyticsChoice(true);
break;
case "disable-analytics":
await handleAnalyticsChoice(false);
break;
}
},
[
actions,
handleAnalyticsChoice,
handleDownloadSelected,
navigate,
runtimeState.selectedRole,
serverExperience.effectiveIsAdmin,
],
);
const isRTL = typeof document !== "undefined" ? document.documentElement.dir === "rtl" : false;
const [isTourOpen, setIsTourOpen] = useState(false);
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
@@ -167,60 +180,63 @@ export default function Onboarding() {
const adminTourOrch = useAdminTourOrchestration();
const userStepsConfig = useMemo(
() => createUserStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
selectCropTool: tourOrch.selectCropTool,
loadSampleFile: tourOrch.loadSampleFile,
switchToActiveFiles: tourOrch.switchToActiveFiles,
pinFile: tourOrch.pinFile,
modifyCropSettings: tourOrch.modifyCropSettings,
executeTool: tourOrch.executeTool,
openFilesModal,
},
}),
[t, tourOrch, closeFilesModal, openFilesModal]
() =>
createUserStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
selectCropTool: tourOrch.selectCropTool,
loadSampleFile: tourOrch.loadSampleFile,
switchToActiveFiles: tourOrch.switchToActiveFiles,
pinFile: tourOrch.pinFile,
modifyCropSettings: tourOrch.modifyCropSettings,
executeTool: tourOrch.executeTool,
openFilesModal,
},
}),
[t, tourOrch, closeFilesModal, openFilesModal],
);
const whatsNewStepsConfig = useMemo(
() => createWhatsNewStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
openFilesModal,
loadSampleFile: tourOrch.loadSampleFile,
switchToViewer: tourOrch.switchToViewer,
switchToPageEditor: tourOrch.switchToPageEditor,
switchToActiveFiles: tourOrch.switchToActiveFiles,
selectFirstFile: tourOrch.selectFirstFile,
},
}),
[t, tourOrch, closeFilesModal, openFilesModal]
() =>
createWhatsNewStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
openFilesModal,
loadSampleFile: tourOrch.loadSampleFile,
switchToViewer: tourOrch.switchToViewer,
switchToPageEditor: tourOrch.switchToPageEditor,
switchToActiveFiles: tourOrch.switchToActiveFiles,
selectFirstFile: tourOrch.selectFirstFile,
},
}),
[t, tourOrch, closeFilesModal, openFilesModal],
);
const adminStepsConfig = useMemo(
() => createAdminStepsConfig({
t,
actions: {
saveAdminState: adminTourOrch.saveAdminState,
openConfigModal: adminTourOrch.openConfigModal,
navigateToSection: adminTourOrch.navigateToSection,
scrollNavToSection: adminTourOrch.scrollNavToSection,
},
}),
[t, adminTourOrch]
() =>
createAdminStepsConfig({
t,
actions: {
saveAdminState: adminTourOrch.saveAdminState,
openConfigModal: adminTourOrch.openConfigModal,
navigateToSection: adminTourOrch.navigateToSection,
scrollNavToSection: adminTourOrch.scrollNavToSection,
},
}),
[t, adminTourOrch],
);
const tourSteps = useMemo<StepType[]>(() => {
switch (runtimeState.tourType) {
case 'admin':
case "admin":
return Object.values(adminStepsConfig);
case 'whatsnew':
case "whatsnew":
return Object.values(whatsNewStepsConfig);
default:
return Object.values(userStepsConfig);
@@ -242,8 +258,8 @@ export default function Onboarding() {
// Handle first-login password change modal
useEffect(() => {
if(runtimeState.requiresPasswordChange === true) {
console.log('[Onboarding] User requires password change on first login.');
if (runtimeState.requiresPasswordChange === true) {
console.log("[Onboarding] User requires password change on first login.");
setFirstLoginModalOpen(true);
} else {
setFirstLoginModalOpen(false);
@@ -252,18 +268,18 @@ export default function Onboarding() {
// Handle MFA setup modal
useEffect(() => {
if(runtimeState.requiresMfaSetup === true) {
console.log('[Onboarding] User requires MFA setup.');
if (runtimeState.requiresMfaSetup === true) {
console.log("[Onboarding] User requires MFA setup.");
setMfaModalOpen(true);
} else {
console.log('[Onboarding] User does not require MFA setup.');
console.log("[Onboarding] User does not require MFA setup.");
setMfaModalOpen(false);
}
}, [runtimeState.requiresMfaSetup]);
const finishTour = useCallback(() => {
setIsTourOpen(false);
if (runtimeState.tourType === 'admin') {
if (runtimeState.tourType === "admin") {
adminTourOrch.restoreAdminState();
} else {
tourOrch.restoreWorkbenchState();
@@ -272,23 +288,29 @@ export default function Onboarding() {
actions.complete();
}, [actions, adminTourOrch, runtimeState.tourType, tourOrch]);
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
if (steps && tourCurrentStep === steps.length - 1) {
setIsOpen(false);
finishTour();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
}
}, [finishTour]);
const handleAdvanceTour = useCallback(
(args: AdvanceArgs) => {
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
if (steps && tourCurrentStep === steps.length - 1) {
setIsOpen(false);
finishTour();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
}
},
[finishTour],
);
const handleCloseTour = useCallback((args: CloseArgs) => {
args.setIsOpen(false);
finishTour();
}, [finishTour]);
const handleCloseTour = useCallback(
(args: CloseArgs) => {
args.setIsOpen(false);
finishTour();
},
[finishTour],
);
const currentSlideDefinition = useMemo(() => {
if (!currentStep || currentStep.type !== 'modal-slide' || !currentStep.slideId) {
if (!currentStep || currentStep.type !== "modal-slide" || !currentStep.slideId) {
return null;
}
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
@@ -312,15 +334,29 @@ export default function Onboarding() {
analyticsLoading,
onMfaSetupComplete: handleMfaSetupComplete,
});
}, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged, handleMfaSetupComplete]);
}, [
analyticsError,
analyticsLoading,
currentSlideDefinition,
osInfo,
osOptions,
runtimeState.selectedRole,
runtimeState.licenseNotice,
handleRoleSelect,
serverExperience.loginEnabled,
setSelectedDownloadUrl,
runtimeState.firstLoginUsername,
handlePasswordChanged,
handleMfaSetupComplete,
]);
const modalSlideCount = useMemo(() => {
return activeFlow.filter((step) => step.type === 'modal-slide').length;
return activeFlow.filter((step) => step.type === "modal-slide").length;
}, [activeFlow]);
const currentModalSlideIndex = useMemo(() => {
if (!currentStep || currentStep.type !== 'modal-slide') return 0;
const modalSlides = activeFlow.filter((step) => step.type === 'modal-slide');
if (!currentStep || currentStep.type !== "modal-slide") return 0;
const modalSlides = activeFlow.filter((step) => step.type === "modal-slide");
return modalSlides.findIndex((step) => step.id === currentStep.id);
}, [activeFlow, currentStep]);
@@ -334,10 +370,10 @@ export default function Onboarding() {
// Show analytics modal before onboarding if needed
if (showAnalyticsModal) {
const slideDefinition = SLIDE_DEFINITIONS['analytics-choice'];
const slideDefinition = SLIDE_DEFINITIONS["analytics-choice"];
const slideContent = slideDefinition.createSlide({
osLabel: '',
osUrl: '',
osLabel: "",
osUrl: "",
selectedRole: null,
onRoleSelect: () => {},
analyticsError,
@@ -353,9 +389,9 @@ export default function Onboarding() {
currentModalSlideIndex={0}
onSkip={() => {}} // No skip allowed
onAction={async (action) => {
if (action === 'enable-analytics') {
if (action === "enable-analytics") {
await handleAnalyticsChoice(true);
} else if (action === 'disable-analytics') {
} else if (action === "disable-analytics") {
await handleAnalyticsChoice(false);
}
}}
@@ -365,10 +401,10 @@ export default function Onboarding() {
}
if (firstLoginModalOpen) {
const baseSlideDefinition = SLIDE_DEFINITIONS['first-login'];
const baseSlideDefinition = SLIDE_DEFINITIONS["first-login"];
const slideContent = baseSlideDefinition.createSlide({
osLabel: '',
osUrl: '',
osLabel: "",
osUrl: "",
selectedRole: null,
onRoleSelect: () => {},
firstLoginUsername: runtimeState.firstLoginUsername,
@@ -385,7 +421,7 @@ export default function Onboarding() {
currentModalSlideIndex={0}
onSkip={() => {}}
onAction={async (action) => {
if (action === 'complete-close') {
if (action === "complete-close") {
handlePasswordChanged();
}
}}
@@ -395,11 +431,11 @@ export default function Onboarding() {
}
if (mfaModalOpen) {
console.log('[Onboarding] Rendering MFA setup modal slide.');
const baseSlideDefinition = SLIDE_DEFINITIONS['mfa-setup'];
console.log("[Onboarding] Rendering MFA setup modal slide.");
const baseSlideDefinition = SLIDE_DEFINITIONS["mfa-setup"];
const slideContent = baseSlideDefinition.createSlide({
osLabel: '',
osUrl: '',
osLabel: "",
osUrl: "",
selectedRole: null,
onRoleSelect: () => {},
onMfaSetupComplete: handleMfaSetupComplete,
@@ -414,7 +450,7 @@ export default function Onboarding() {
currentModalSlideIndex={0}
onSkip={() => {}}
onAction={async (action) => {
if (action === 'complete-close') {
if (action === "complete-close") {
handleMfaSetupComplete();
}
}}
@@ -424,16 +460,16 @@ export default function Onboarding() {
}
if (showLicenseSlide) {
const baseSlideDefinition = SLIDE_DEFINITIONS['server-license'];
const baseSlideDefinition = SLIDE_DEFINITIONS["server-license"];
// Remove back button for external license notice
const slideDefinition = {
...baseSlideDefinition,
buttons: baseSlideDefinition.buttons.filter(btn => btn.key !== 'license-back')
buttons: baseSlideDefinition.buttons.filter((btn) => btn.key !== "license-back"),
};
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
const slideContent = slideDefinition.createSlide({
osLabel: '',
osUrl: '',
osLabel: "",
osUrl: "",
osOptions: [],
onDownloadUrlChange: () => {},
selectedRole: null,
@@ -451,9 +487,9 @@ export default function Onboarding() {
currentModalSlideIndex={0}
onSkip={closeLicenseSlide}
onAction={(action) => {
if (action === 'see-plans') {
if (action === "see-plans") {
closeLicenseSlide();
navigate('/settings/adminPlan');
navigate("/settings/adminPlan");
} else {
closeLicenseSlide();
}
@@ -487,10 +523,10 @@ export default function Onboarding() {
// Render the current onboarding step
switch (currentStep.type) {
case 'tool-prompt':
case "tool-prompt":
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
case 'modal-slide':
case "modal-slide":
if (!currentSlideDefinition || !currentSlideContent) return null;
return (
<OnboardingModalSlide
@@ -1,25 +1,25 @@
/**
* 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, ActionIcon } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import CloseIcon from '@mui/icons-material/Close';
import React from "react";
import { Modal, Stack, ActionIcon } from "@mantine/core";
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
import CloseIcon from "@mui/icons-material/Close";
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 { 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';
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 { 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";
interface OnboardingModalSlideProps {
slideDefinition: SlideDefinition;
@@ -42,9 +42,8 @@ export default function OnboardingModalSlide({
onAction,
allowDismiss = true,
}: OnboardingModalSlideProps) {
const renderHero = () => {
if (slideDefinition.hero.type === 'dual-icon') {
if (slideDefinition.hero.type === "dual-icon") {
return (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
@@ -56,20 +55,20 @@ export default function OnboardingModalSlide({
return (
<div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === 'rocket' && (
{slideDefinition.hero.type === "rocket" && (
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'shield' && (
{slideDefinition.hero.type === "shield" && (
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'lock' && (
{slideDefinition.hero.type === "lock" && (
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'analytics' && (
{slideDefinition.hero.type === "analytics" && (
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
{slideDefinition.hero.type === 'logo' && (
{slideDefinition.hero.type === "diamond" && <DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />}
{slideDefinition.hero.type === "logo" && (
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
)}
</div>
@@ -88,8 +87,8 @@ export default function OnboardingModalSlide({
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0, maxHeight: '90vh', overflow: 'hidden' },
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)', maxHeight: '90vh' },
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
content: { overflow: "hidden", border: "none", background: "var(--bg-surface)", maxHeight: "90vh" },
}}
>
<Stack gap={0} className={styles.modalContent}>
@@ -106,18 +105,18 @@ export default function OnboardingModalSlide({
radius="md"
size={36}
style={{
position: 'absolute',
position: "absolute",
top: 16,
right: 16,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
backdropFilter: 'blur(4px)',
backgroundColor: "rgba(255, 255, 255, 0.2)",
color: "white",
backdropFilter: "blur(4px)",
zIndex: 10,
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.3)',
"&:hover": {
backgroundColor: "rgba(255, 255, 255, 0.3)",
},
},
}}
@@ -130,12 +129,9 @@ export default function OnboardingModalSlide({
</div>
</div>
<div className={styles.modalBody} style={{ overflowY: 'auto', maxHeight: 'calc(90vh - 220px)' }}>
<div className={styles.modalBody} style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}>
<Stack gap={16}>
<div
key={`title-${slideContent.key}`}
className={`${styles.title} ${styles.titleText}`}
>
<div key={`title-${slideContent.key}`} className={`${styles.title} ${styles.titleText}`}>
{slideContent.title}
</div>
@@ -146,9 +142,7 @@ export default function OnboardingModalSlide({
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
{modalSlideCount > 1 && (
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
)}
{modalSlideCount > 1 && <OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />}
<div className={styles.buttonContainer}>
<SlideButtons
@@ -164,4 +158,3 @@ export default function OnboardingModalSlide({
</Modal>
);
}
@@ -1,4 +1,4 @@
import React from 'react';
import React from "react";
interface OnboardingStepperProps {
totalSteps: number;
@@ -17,18 +17,16 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard
<div
className={className}
style={{
display: 'flex',
display: "flex",
gap: 8,
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
}}
>
{items.map((index) => {
const isActive = index === activeStep;
const baseStyles: React.CSSProperties = {
background: isActive
? 'var(--onboarding-step-active)'
: 'var(--onboarding-step-inactive)',
background: isActive ? "var(--onboarding-step-active)" : "var(--onboarding-step-inactive)",
};
return (
@@ -48,5 +46,3 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard
}
export default OnboardingStepper;
@@ -18,7 +18,8 @@
}
@keyframes pulse-glow {
0%, 100% {
0%,
100% {
box-shadow:
0 0 0 3px var(--mantine-primary-color-filled),
0 0 20px var(--mantine-primary-color-filled),
@@ -33,13 +34,13 @@
}
/* RTL: mirror step indicator and controls in Reactour popovers */
:root[dir='rtl'] .reactour__popover {
:root[dir="rtl"] .reactour__popover {
direction: rtl;
}
/* Minimal overrides retained for glow only */
:root[dir='rtl'] .reactour__badge {
:root[dir="rtl"] .reactour__badge {
left: auto;
right: 16px;
}
@@ -1,19 +1,19 @@
/**
* OnboardingTour Component
*
*
* Reusable tour wrapper that encapsulates all Reactour configuration.
* Used by the main Onboarding component for both the 'tour' step and
* when the tour is open but onboarding is inactive.
*/
import React from 'react';
import { TourProvider, useTour, type StepType } from '@reactour/tour';
import { CloseButton, ActionIcon } from '@mantine/core';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import CheckIcon from '@mui/icons-material/Check';
import type { TFunction } from 'i18next';
import i18n from '@app/i18n';
import React from "react";
import { TourProvider, useTour, type StepType } from "@reactour/tour";
import { CloseButton, ActionIcon } from "@mantine/core";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import CheckIcon from "@mui/icons-material/Check";
import type { TFunction } from "i18next";
import i18n from "@app/i18n";
/**
* TourContent - Controls the tour visibility
@@ -49,7 +49,7 @@ interface CloseArgs {
interface OnboardingTourProps {
tourSteps: StepType[];
tourType: 'admin' | 'tools' | 'whatsnew';
tourType: "admin" | "tools" | "whatsnew";
isRTL: boolean;
t: TFunction;
isOpen: boolean;
@@ -57,22 +57,14 @@ interface OnboardingTourProps {
onClose: (args: CloseArgs) => void;
}
export default function OnboardingTour({
tourSteps,
tourType,
isRTL,
t,
isOpen,
onAdvance,
onClose,
}: OnboardingTourProps) {
export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen, onAdvance, onClose }: OnboardingTourProps) {
if (!isOpen) return null;
return (
<TourProvider
key={`${tourType}-${i18n.language}`}
steps={tourSteps}
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
maskClassName={tourType === "admin" ? "admin-tour-mask" : undefined}
onClickClose={onClose}
onClickMask={onAdvance}
onClickHighlighted={(e, clickProps) => {
@@ -80,10 +72,10 @@ export default function OnboardingTour({
onAdvance(clickProps);
}}
keyboardHandler={(e, clickProps, status) => {
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
if (e.key === "ArrowRight" && !status?.isRightDisabled && clickProps) {
e.preventDefault();
onAdvance(clickProps);
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
} else if (e.key === "Escape" && !status?.isEscDisabled && clickProps) {
e.preventDefault();
onClose(clickProps);
}
@@ -92,12 +84,12 @@ export default function OnboardingTour({
styles={{
popover: (base) => ({
...base,
backgroundColor: 'var(--mantine-color-body)',
color: 'var(--mantine-color-text)',
borderRadius: '8px',
padding: '20px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
maxWidth: '400px',
backgroundColor: "var(--mantine-color-body)",
color: "var(--mantine-color-text)",
borderRadius: "8px",
padding: "20px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
maxWidth: "400px",
}),
maskArea: (base) => ({
...base,
@@ -105,11 +97,11 @@ export default function OnboardingTour({
}),
badge: (base) => ({
...base,
backgroundColor: 'var(--mantine-primary-color-filled)',
backgroundColor: "var(--mantine-primary-color-filled)",
}),
controls: (base) => ({
...base,
justifyContent: 'center',
justifyContent: "center",
}),
}}
highlightedMaskClassName="tour-highlight-glow"
@@ -127,7 +119,7 @@ export default function OnboardingTour({
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })}
variant="subtle"
size="lg"
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
aria-label={isLast ? t("onboarding.finish", "Finish") : t("onboarding.next", "Next")}
>
{isLast ? <CheckIcon /> : <ArrowIcon />}
</ActionIcon>
@@ -135,10 +127,10 @@ export default function OnboardingTour({
}}
components={{
Close: ({ onClick }) => (
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
<CloseButton onClick={onClick} size="md" style={{ position: "absolute", top: "8px", right: "8px" }} />
),
Content: ({ content }: { content: string }) => (
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
<div style={{ paddingRight: "16px" }} dangerouslySetInnerHTML={{ __html: content }} />
),
}}
>
@@ -148,4 +140,3 @@ export default function OnboardingTour({
}
export type { AdvanceArgs, CloseArgs };
@@ -1,6 +1,6 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow';
import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next";
import { addGlowToElements, removeAllGlows } from "@app/components/onboarding/tourGlow";
export enum AdminTourStep {
WELCOME,
@@ -14,7 +14,7 @@ export enum AdminTourStep {
WRAP_UP,
}
interface AdminStepActions {
interface AdminStepActions {
saveAdminState: () => void;
openConfigModal: () => void;
navigateToSection: (section: string) => void;
@@ -32,8 +32,11 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
return {
[AdminTourStep.WELCOME]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
position: 'right',
content: t(
"adminOnboarding.welcome",
"Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators.",
),
position: "right",
padding: 10,
action: () => {
saveAdminState();
@@ -41,17 +44,23 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
},
[AdminTourStep.CONFIG_BUTTON]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.configButton', "Click the <strong>Config</strong> button to access all system settings and administrative controls."),
position: 'right',
content: t(
"adminOnboarding.configButton",
"Click the <strong>Config</strong> button to access all system settings and administrative controls.",
),
position: "right",
padding: 10,
actionAfter: () => {
openConfigModal();
},
},
[AdminTourStep.SETTINGS_OVERVIEW]: {
selector: '.modal-nav',
content: t('adminOnboarding.settingsOverview', "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."),
position: 'right',
selector: ".modal-nav",
content: t(
"adminOnboarding.settingsOverview",
"This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation.",
),
position: "right",
padding: 0,
action: () => {
removeAllGlows();
@@ -59,41 +68,68 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
},
[AdminTourStep.TEAMS_AND_USERS]: {
selector: '[data-tour="admin-people-nav"]',
highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.teamsAndUsers', "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
position: 'right',
highlightedSelectors: [
'[data-tour="admin-people-nav"]',
'[data-tour="admin-teams-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.teamsAndUsers",
"Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('people');
navigateToSection("people");
setTimeout(() => {
addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']);
addGlowToElements([
'[data-tour="admin-people-nav"]',
'[data-tour="admin-teams-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
selector: '[data-tour="admin-adminGeneral-nav"]',
highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."),
position: 'right',
highlightedSelectors: [
'[data-tour="admin-adminGeneral-nav"]',
'[data-tour="admin-adminFeatures-nav"]',
'[data-tour="admin-adminEndpoints-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.systemCustomization",
"We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminGeneral');
navigateToSection("adminGeneral");
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']);
addGlowToElements([
'[data-tour="admin-adminGeneral-nav"]',
'[data-tour="admin-adminFeatures-nav"]',
'[data-tour="admin-adminEndpoints-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.DATABASE_SECTION]: {
selector: '[data-tour="admin-adminDatabase-nav"]',
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."),
position: 'right',
content: t(
"adminOnboarding.databaseSection",
"For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminDatabase');
navigateToSection("adminDatabase");
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
@@ -102,38 +138,55 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
[AdminTourStep.CONNECTIONS_SECTION]: {
selector: '[data-tour="admin-adminConnections-nav"]',
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.connectionsSection', "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
position: 'right',
content: t(
"adminOnboarding.connectionsSection",
"The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminConnections');
navigateToSection("adminConnections");
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
actionAfter: async () => {
await scrollNavToSection('adminAudit');
await scrollNavToSection("adminAudit");
},
},
[AdminTourStep.ADMIN_TOOLS]: {
selector: '[data-tour="admin-adminAudit-nav"]',
highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."),
position: 'right',
highlightedSelectors: [
'[data-tour="admin-adminAudit-nav"]',
'[data-tour="admin-adminUsage-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.adminTools",
"Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminAudit');
navigateToSection("adminAudit");
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']);
addGlowToElements([
'[data-tour="admin-adminAudit-nav"]',
'[data-tour="admin-adminUsage-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."),
position: 'right',
content: t(
"adminOnboarding.wrapUp",
"That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
@@ -141,4 +194,3 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
},
};
}
@@ -1,45 +1,45 @@
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide';
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide';
import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide';
import MFASetupSlide from '@app/components/onboarding/slides/MFASetupSlide';
import { SlideConfig, LicenseNotice } from '@app/types/types';
import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide";
import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide";
import SecurityCheckSlide from "@app/components/onboarding/slides/SecurityCheckSlide";
import PlanOverviewSlide from "@app/components/onboarding/slides/PlanOverviewSlide";
import ServerLicenseSlide from "@app/components/onboarding/slides/ServerLicenseSlide";
import FirstLoginSlide from "@app/components/onboarding/slides/FirstLoginSlide";
import TourOverviewSlide from "@app/components/onboarding/slides/TourOverviewSlide";
import AnalyticsChoiceSlide from "@app/components/onboarding/slides/AnalyticsChoiceSlide";
import MFASetupSlide from "@app/components/onboarding/slides/MFASetupSlide";
import { SlideConfig, LicenseNotice } from "@app/types/types";
export type SlideId =
| 'first-login'
| 'welcome'
| 'desktop-install'
| 'security-check'
| 'admin-overview'
| 'server-license'
| 'tour-overview'
| 'analytics-choice'
| 'mfa-setup';
| "first-login"
| "welcome"
| "desktop-install"
| "security-check"
| "admin-overview"
| "server-license"
| "tour-overview"
| "analytics-choice"
| "mfa-setup";
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics';
export type HeroType = "rocket" | "dual-icon" | "shield" | "diamond" | "logo" | "lock" | "analytics";
export type ButtonAction =
| 'next'
| 'prev'
| 'close'
| 'complete-close'
| 'download-selected'
| 'security-next'
| 'launch-admin'
| 'launch-tools'
| 'launch-auto'
| 'see-plans'
| 'skip-to-license'
| 'skip-tour'
| 'enable-analytics'
| 'disable-analytics';
| "next"
| "prev"
| "close"
| "complete-close"
| "download-selected"
| "security-next"
| "launch-admin"
| "launch-tools"
| "launch-auto"
| "see-plans"
| "skip-to-license"
| "skip-tour"
| "enable-analytics"
| "disable-analytics";
export interface FlowState {
selectedRole: 'admin' | 'user' | null;
selectedRole: "admin" | "user" | null;
}
export interface OSOption {
@@ -53,8 +53,8 @@ export interface SlideFactoryParams {
osUrl: string;
osOptions?: OSOption[];
onDownloadUrlChange?: (url: string) => void;
selectedRole: 'admin' | 'user' | null;
onRoleSelect: (role: 'admin' | 'user' | null) => void;
selectedRole: "admin" | "user" | null;
onRoleSelect: (role: "admin" | "user" | null) => void;
licenseNotice?: LicenseNotice;
loginEnabled?: boolean;
// First login params
@@ -72,11 +72,11 @@ export interface HeroDefinition {
export interface ButtonDefinition {
key: string;
type: 'button' | 'icon';
type: "button" | "icon";
label?: string;
icon?: 'chevron-left';
variant?: 'primary' | 'secondary' | 'default';
group: 'left' | 'right';
icon?: "chevron-left";
variant?: "primary" | "secondary" | "default";
group: "left" | "right";
action: ButtonAction;
disabledWhen?: (state: FlowState) => boolean;
}
@@ -89,206 +89,204 @@ export interface SlideDefinition {
}
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'first-login': {
id: 'first-login',
"first-login": {
id: "first-login",
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
FirstLoginSlide({
username: firstLoginUsername || '',
username: firstLoginUsername || "",
onPasswordChanged: onPasswordChanged || (() => {}),
usingDefaultCredentials: usingDefaultCredentials || false,
}),
hero: { type: 'lock' },
hero: { type: "lock" },
buttons: [], // Form has its own submit button
},
'welcome': {
id: 'welcome',
welcome: {
id: "welcome",
createSlide: () => WelcomeSlide(),
hero: { type: 'rocket' },
hero: { type: "rocket" },
buttons: [
{
key: 'welcome-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'next',
key: "welcome-next",
type: "button",
label: "onboarding.buttons.next",
variant: "primary",
group: "right",
action: "next",
},
],
},
'desktop-install': {
id: 'desktop-install',
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: 'dual-icon' },
"desktop-install": {
id: "desktop-install",
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) =>
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: "dual-icon" },
buttons: [
{
key: 'desktop-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
key: "desktop-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: 'desktop-skip',
type: 'button',
label: 'onboarding.buttons.skipForNow',
variant: 'secondary',
group: 'left',
action: 'next',
key: "desktop-skip",
type: "button",
label: "onboarding.buttons.skipForNow",
variant: "secondary",
group: "left",
action: "next",
},
{
key: 'desktop-download',
type: 'button',
label: 'onboarding.buttons.download',
variant: 'primary',
group: 'right',
action: 'download-selected',
key: "desktop-download",
type: "button",
label: "onboarding.buttons.download",
variant: "primary",
group: "right",
action: "download-selected",
},
],
},
'security-check': {
id: 'security-check',
createSlide: ({ selectedRole, onRoleSelect }) =>
SecurityCheckSlide({ selectedRole, onRoleSelect }),
hero: { type: 'shield' },
"security-check": {
id: "security-check",
createSlide: ({ selectedRole, onRoleSelect }) => SecurityCheckSlide({ selectedRole, onRoleSelect }),
hero: { type: "shield" },
buttons: [
{
key: 'security-back',
type: 'button',
label: 'onboarding.buttons.back',
variant: 'secondary',
group: 'left',
action: 'prev',
key: "security-back",
type: "button",
label: "onboarding.buttons.back",
variant: "secondary",
group: "left",
action: "prev",
},
{
key: 'security-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'security-next',
key: "security-next",
type: "button",
label: "onboarding.buttons.next",
variant: "primary",
group: "right",
action: "security-next",
disabledWhen: (state) => !state.selectedRole,
},
],
},
'admin-overview': {
id: 'admin-overview',
createSlide: ({ licenseNotice, loginEnabled }) =>
PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
hero: { type: 'diamond' },
"admin-overview": {
id: "admin-overview",
createSlide: ({ licenseNotice, loginEnabled }) => PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
hero: { type: "diamond" },
buttons: [
{
key: 'admin-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
key: "admin-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: 'admin-show',
type: 'button',
label: 'onboarding.buttons.showMeAround',
variant: 'primary',
group: 'right',
action: 'launch-admin',
key: "admin-show",
type: "button",
label: "onboarding.buttons.showMeAround",
variant: "primary",
group: "right",
action: "launch-admin",
},
{
key: 'admin-skip',
type: 'button',
label: 'onboarding.buttons.skipTheTour',
variant: 'secondary',
group: 'left',
action: 'skip-to-license',
key: "admin-skip",
type: "button",
label: "onboarding.buttons.skipTheTour",
variant: "secondary",
group: "left",
action: "skip-to-license",
},
],
},
'server-license': {
id: 'server-license',
"server-license": {
id: "server-license",
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
hero: { type: 'dual-icon' },
hero: { type: "dual-icon" },
buttons: [
{
key: 'license-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
key: "license-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: 'license-close',
type: 'button',
label: 'onboarding.buttons.skipForNow',
variant: 'secondary',
group: 'left',
action: 'close',
key: "license-close",
type: "button",
label: "onboarding.buttons.skipForNow",
variant: "secondary",
group: "left",
action: "close",
},
{
key: 'license-see-plans',
type: 'button',
label: 'onboarding.serverLicense.seePlans',
variant: 'primary',
group: 'right',
action: 'see-plans',
key: "license-see-plans",
type: "button",
label: "onboarding.serverLicense.seePlans",
variant: "primary",
group: "right",
action: "see-plans",
},
],
},
'tour-overview': {
id: 'tour-overview',
"tour-overview": {
id: "tour-overview",
createSlide: () => TourOverviewSlide(),
hero: { type: 'rocket' },
hero: { type: "rocket" },
buttons: [
{
key: 'tour-overview-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
key: "tour-overview-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: 'tour-overview-skip',
type: 'button',
label: 'onboarding.buttons.skipForNow',
variant: 'secondary',
group: 'left',
action: 'skip-tour',
key: "tour-overview-skip",
type: "button",
label: "onboarding.buttons.skipForNow",
variant: "secondary",
group: "left",
action: "skip-tour",
},
{
key: 'tour-overview-show',
type: 'button',
label: 'onboarding.buttons.showMeAround',
variant: 'primary',
group: 'right',
action: 'launch-tools',
key: "tour-overview-show",
type: "button",
label: "onboarding.buttons.showMeAround",
variant: "primary",
group: "right",
action: "launch-tools",
},
],
},
'analytics-choice': {
id: 'analytics-choice',
"analytics-choice": {
id: "analytics-choice",
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }),
hero: { type: 'analytics' },
hero: { type: "analytics" },
buttons: [
{
key: 'analytics-disable',
type: 'button',
label: 'no',
variant: 'secondary',
group: 'left',
action: 'disable-analytics',
key: "analytics-disable",
type: "button",
label: "no",
variant: "secondary",
group: "left",
action: "disable-analytics",
},
{
key: 'analytics-enable',
type: 'button',
label: 'yes',
variant: 'primary',
group: 'right',
action: 'enable-analytics',
key: "analytics-enable",
type: "button",
label: "yes",
variant: "primary",
group: "right",
action: "enable-analytics",
},
],
},
'mfa-setup': {
id: 'mfa-setup',
"mfa-setup": {
id: "mfa-setup",
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }),
hero: { type: 'lock' },
hero: { type: "lock" },
buttons: [], // Form has its own submit button
},
};
@@ -1,23 +1,21 @@
export type OnboardingStepId =
| 'first-login'
| 'welcome'
| 'desktop-install'
| 'security-check'
| 'admin-overview'
| 'tool-layout'
| 'tour-overview'
| 'server-license'
| 'analytics-choice'
| 'mfa-setup';
| "first-login"
| "welcome"
| "desktop-install"
| "security-check"
| "admin-overview"
| "tool-layout"
| "tour-overview"
| "server-license"
| "analytics-choice"
| "mfa-setup";
export type OnboardingStepType =
| 'modal-slide'
| 'tool-prompt';
export type OnboardingStepType = "modal-slide" | "tool-prompt";
export interface OnboardingRuntimeState {
selectedRole: 'admin' | 'user' | null;
selectedRole: "admin" | "user" | null;
tourRequested: boolean;
tourType: 'admin' | 'tools' | 'whatsnew';
tourType: "admin" | "tools" | "whatsnew";
isDesktopApp: boolean;
desktopSlideEnabled: boolean;
analyticsNotConfigured: boolean;
@@ -43,14 +41,23 @@ export interface OnboardingStep {
id: OnboardingStepId;
type: OnboardingStepType;
condition: (ctx: OnboardingConditionContext) => boolean;
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice' | 'mfa-setup';
slideId?:
| "first-login"
| "welcome"
| "desktop-install"
| "security-check"
| "admin-overview"
| "server-license"
| "tour-overview"
| "analytics-choice"
| "mfa-setup";
allowDismiss?: boolean;
}
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
selectedRole: null,
tourRequested: false,
tourType: 'whatsnew',
tourType: "whatsnew",
isDesktopApp: false,
analyticsNotConfigured: false,
analyticsEnabled: false,
@@ -61,7 +68,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
requiresLicense: false,
},
requiresPasswordChange: false,
firstLoginUsername: '',
firstLoginUsername: "",
usingDefaultCredentials: false,
desktopSlideEnabled: true,
requiresMfaSetup: false,
@@ -69,59 +76,59 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
export const ONBOARDING_STEPS: OnboardingStep[] = [
{
id: 'first-login',
type: 'modal-slide',
slideId: 'first-login',
id: "first-login",
type: "modal-slide",
slideId: "first-login",
condition: (ctx) => ctx.requiresPasswordChange,
},
{
id: 'welcome',
type: 'modal-slide',
slideId: 'welcome',
id: "welcome",
type: "modal-slide",
slideId: "welcome",
// Desktop has its own onboarding modal (DesktopOnboardingModal)
condition: (ctx) => !ctx.isDesktopApp,
},
{
id: 'admin-overview',
type: 'modal-slide',
slideId: 'admin-overview',
id: "admin-overview",
type: "modal-slide",
slideId: "admin-overview",
condition: (ctx) => ctx.effectiveIsAdmin,
},
{
id: 'desktop-install',
type: 'modal-slide',
slideId: 'desktop-install',
id: "desktop-install",
type: "modal-slide",
slideId: "desktop-install",
condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled,
},
{
id: 'security-check',
type: 'modal-slide',
slideId: 'security-check',
id: "security-check",
type: "modal-slide",
slideId: "security-check",
condition: () => false,
},
{
id: 'tool-layout',
type: 'tool-prompt',
id: "tool-layout",
type: "tool-prompt",
condition: () => false,
},
{
id: 'tour-overview',
type: 'modal-slide',
slideId: 'tour-overview',
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin' && !ctx.isDesktopApp,
id: "tour-overview",
type: "modal-slide",
slideId: "tour-overview",
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp,
},
{
id: 'server-license',
type: 'modal-slide',
slideId: 'server-license',
id: "server-license",
type: "modal-slide",
slideId: "server-license",
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
},
{
id: 'mfa-setup',
type: 'modal-slide',
slideId: 'mfa-setup',
id: "mfa-setup",
type: "modal-slide",
slideId: "mfa-setup",
condition: (ctx) => ctx.requiresMfaSetup,
}
},
];
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
@@ -131,4 +138,3 @@ export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
export function getStepIndex(id: OnboardingStepId): number {
return ONBOARDING_STEPS.findIndex((step) => step.id === id);
}
@@ -1,62 +1,62 @@
const STORAGE_PREFIX = 'onboarding';
const STORAGE_PREFIX = "onboarding";
const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`;
const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`;
export function isOnboardingCompleted(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window === "undefined") return false;
try {
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true';
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === "true";
} catch {
return false;
}
}
export function markOnboardingCompleted(): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true');
localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");
} catch (error) {
console.error('[onboardingStorage] Error marking onboarding as completed:', error);
console.error("[onboardingStorage] Error marking onboarding as completed:", error);
}
}
export function resetOnboardingProgress(): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
} catch (error) {
console.error('[onboardingStorage] Error resetting onboarding progress:', error);
console.error("[onboardingStorage] Error resetting onboarding progress:", error);
}
}
export function hasShownToursTooltip(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window === "undefined") return false;
try {
return localStorage.getItem(TOURS_TOOLTIP_KEY) === 'true';
return localStorage.getItem(TOURS_TOOLTIP_KEY) === "true";
} catch {
return false;
}
}
export function markToursTooltipShown(): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
localStorage.setItem(TOURS_TOOLTIP_KEY, 'true');
localStorage.setItem(TOURS_TOOLTIP_KEY, "true");
} catch (error) {
console.error('[onboardingStorage] Error marking tours tooltip as shown:', error);
console.error("[onboardingStorage] Error marking tours tooltip as shown:", error);
}
}
export function migrateFromLegacyPreferences(): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
const migrationKey = `${STORAGE_PREFIX}::migrated`;
try {
// Skip if already migrated
if (localStorage.getItem(migrationKey) === 'true') return;
if (localStorage.getItem(migrationKey) === "true") return;
const prefsRaw = localStorage.getItem('stirlingpdf_preferences');
const prefsRaw = localStorage.getItem("stirlingpdf_preferences");
if (prefsRaw) {
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
@@ -67,7 +67,7 @@ export function migrateFromLegacyPreferences(): void {
}
// Mark migration complete
localStorage.setItem(migrationKey, 'true');
localStorage.setItem(migrationKey, "true");
} catch {
// If migration fails, onboarding will show again - safer than hiding it
}
@@ -1,7 +1,7 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useServerExperience } from '@app/hooks/useServerExperience';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { useServerExperience } from "@app/hooks/useServerExperience";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import {
ONBOARDING_STEPS,
@@ -10,39 +10,40 @@ import {
type OnboardingRuntimeState,
type OnboardingConditionContext,
DEFAULT_RUNTIME_STATE,
} from '@app/components/onboarding/orchestrator/onboardingConfig';
} from "@app/components/onboarding/orchestrator/onboardingConfig";
import {
isOnboardingCompleted,
markOnboardingCompleted,
migrateFromLegacyPreferences,
} from '@app/components/onboarding/orchestrator/onboardingStorage';
import { accountService } from '@app/services/accountService';
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
} from "@app/components/onboarding/orchestrator/onboardingStorage";
import { accountService } from "@app/services/accountService";
import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding";
const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];
const SESSION_TOUR_REQUESTED = 'onboarding::session::tour-requested';
const SESSION_TOUR_TYPE = 'onboarding::session::tour-type';
const SESSION_SELECTED_ROLE = 'onboarding::session::selected-role';
const AUTH_ROUTES = ["/login", "/signup", "/auth", "/invite"];
const SESSION_TOUR_REQUESTED = "onboarding::session::tour-requested";
const SESSION_TOUR_TYPE = "onboarding::session::tour-type";
const SESSION_SELECTED_ROLE = "onboarding::session::selected-role";
// Check if user has an auth token (to avoid flash before redirect)
function hasAuthToken(): boolean {
if (typeof window === 'undefined') return false;
return !!localStorage.getItem('stirling_jwt');
if (typeof window === "undefined") return false;
return !!localStorage.getItem("stirling_jwt");
}
// Get initial runtime state from session storage (survives remounts)
function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState {
if (typeof window === 'undefined') {
if (typeof window === "undefined") {
return baseState;
}
try {
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true';
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true";
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
const tourType = (sessionTourType === 'admin' || sessionTourType === 'tools' || sessionTourType === 'whatsnew')
? sessionTourType
: 'whatsnew';
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
const tourType =
sessionTourType === "admin" || sessionTourType === "tools" || sessionTourType === "whatsnew"
? sessionTourType
: "whatsnew";
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as "admin" | "user" | null;
return {
...baseState,
@@ -56,11 +57,11 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
}
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
if (state.tourRequested !== undefined) {
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false');
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? "true" : "false");
}
if (state.tourType !== undefined) {
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
@@ -73,12 +74,12 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
}
}
} catch (error) {
console.error('[useOnboardingOrchestrator] Error persisting runtime state:', error);
console.error("[useOnboardingOrchestrator] Error persisting runtime state:", error);
}
}
function clearRuntimeStateSession(): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
@@ -94,9 +95,9 @@ function parseMfaRequired(settings: string | null | undefined): boolean {
try {
const parsed = JSON.parse(settings) as { mfaRequired?: string };
return parsed.mfaRequired?.toLowerCase() === 'true';
return parsed.mfaRequired?.toLowerCase() === "true";
} catch (error) {
console.warn('[useOnboardingOrchestrator] Failed to parse account settings JSON:', error);
console.warn("[useOnboardingOrchestrator] Failed to parse account settings JSON:", error);
return false;
}
}
@@ -151,18 +152,14 @@ export interface UseOnboardingOrchestratorOptions {
defaultRuntimeState?: OnboardingRuntimeState;
}
export function useOnboardingOrchestrator(
options?: UseOnboardingOrchestratorOptions
): UseOnboardingOrchestratorResult {
export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOptions): UseOnboardingOrchestratorResult {
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
const serverExperience = useServerExperience();
const { config, loading: configLoading } = useAppConfig();
const location = useLocation();
const bypassOnboarding = useBypassOnboarding();
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
getInitialRuntimeState(defaultState)
);
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() => getInitialRuntimeState(defaultState));
const [isPaused, setIsPaused] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
@@ -186,10 +183,10 @@ export function useOnboardingOrchestrator(
totalUsers: serverExperience.totalUsers,
freeTierLimit: serverExperience.freeTierLimit,
isOverLimit: serverExperience.overFreeTierLimit ?? false,
requiresLicense: !serverExperience.hasPaidLicense && (
serverExperience.overFreeTierLimit === true ||
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)
),
requiresLicense:
!serverExperience.hasPaidLicense &&
(serverExperience.overFreeTierLimit === true ||
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)),
},
}));
}, [
@@ -220,7 +217,7 @@ export function useOnboardingOrchestrator(
requiresMfaSetup: parseMfaRequired(accountData.settings),
}));
} catch (error) {
console.log('[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:', error);
console.log("[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:", error);
// Account endpoint failed - user not logged in or security disabled
}
};
@@ -233,26 +230,25 @@ export function useOnboardingOrchestrator(
const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route));
const loginEnabled = config?.enableLogin === true;
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
const shouldBlockOnboarding =
bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
const shouldBlockOnboarding = bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
...serverExperience,
...runtimeState,
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
}), [serverExperience, runtimeState]);
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
);
const adminStatusResolved =
!configLoading && (config?.enableLogin === false || config?.enableLogin === undefined || config?.isAdmin !== undefined);
useEffect(() => {
if (configLoading || !adminStatusResolved) return;
@@ -280,14 +276,15 @@ export function useOnboardingOrchestrator(
const totalSteps = activeFlow.length;
const isComplete = isInitialized &&
(totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps)
? activeFlow[currentStepIndex]
: null;
const isComplete = isInitialized && (totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
const currentStep = currentStepIndex >= 0 && currentStepIndex < totalSteps ? activeFlow[currentStepIndex] : null;
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
!initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
const isLoading =
configLoading ||
!adminStatusResolved ||
!isInitialized ||
!initialIndexSet.current ||
(currentStepIndex === -1 && activeFlow.length > 0);
useEffect(() => {
if (!configLoading && !isInitialized) setIsInitialized(true);
@@ -325,7 +322,6 @@ export function useOnboardingOrchestrator(
setCurrentStepIndex(nextIndex);
}, [currentStepIndex, totalSteps]);
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
persistRuntimeState(updates);
setRuntimeState((prev) => ({ ...prev, ...updates }));
@@ -336,13 +332,16 @@ export function useOnboardingOrchestrator(
setCurrentStepIndex(-1);
}, []);
const startStep = useCallback((stepId: OnboardingStepId) => {
const index = activeFlow.findIndex((step) => step.id === stepId);
if (index !== -1) {
setCurrentStepIndex(index);
setIsPaused(false);
}
}, [activeFlow]);
const startStep = useCallback(
(stepId: OnboardingStepId) => {
const index = activeFlow.findIndex((step) => step.id === stepId);
if (index !== -1) {
setCurrentStepIndex(index);
setIsPaused(false);
}
},
[activeFlow],
);
const pause = useCallback(() => setIsPaused(true), []);
const resume = useCallback(() => setIsPaused(false), []);
@@ -1,11 +1,11 @@
import React from 'react';
import { Trans } from 'react-i18next';
import { Button } from '@mantine/core';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import i18n from '@app/i18n';
import { SlideConfig } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import React from "react";
import { Trans } from "react-i18next";
import { Button } from "@mantine/core";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import i18n from "@app/i18n";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
interface AnalyticsChoiceSlideProps {
analyticsError?: string | null;
@@ -13,8 +13,8 @@ interface AnalyticsChoiceSlideProps {
export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig {
return {
key: 'analytics-choice',
title: i18n.t('analytics.title', 'Do you want to help make Stirling PDF better?'),
key: "analytics-choice",
title: i18n.t("analytics.title", "Do you want to help make Stirling PDF better?"),
body: (
<div className={styles.bodyCopyInner}>
<Trans
@@ -29,27 +29,22 @@ export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoice
components={{ strong: <strong /> }}
/>
<br />
<div style={{ textAlign: 'right', marginTop: 0 }}>
<div style={{ textAlign: "right", marginTop: 0 }}>
<Button
variant="default"
size="sm"
onClick={() => window.open('https://docs.stirlingpdf.com/analytics-telemetry/', '_blank')}
onClick={() => window.open("https://docs.stirlingpdf.com/analytics-telemetry/", "_blank")}
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
>
{i18n.t('analytics.learnMore', 'Learn more about our analytics')}
{i18n.t("analytics.learnMore", "Learn more about our analytics")}
</Button>
</div>
{analyticsError && (
<div style={{ color: 'var(--mantine-color-red-6)', marginTop: 12 }}>
{analyticsError}
</div>
)}
{analyticsError && <div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>{analyticsError}</div>}
</div>
),
background: {
gradientStops: ['#0EA5E9', '#6366F1'],
gradientStops: ["#0EA5E9", "#6366F1"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,12 +1,12 @@
import React from 'react';
import styles from '@app/components/onboarding/slides/AnimatedSlideBackground.module.css';
import { AnimatedSlideBackgroundProps } from '@app/types/types';
import React from "react";
import styles from "@app/components/onboarding/slides/AnimatedSlideBackground.module.css";
import { AnimatedSlideBackgroundProps } from "@app/types/types";
type CircleStyles = React.CSSProperties & {
'--circle-move-x'?: string;
'--circle-move-y'?: string;
'--circle-duration'?: string;
'--circle-delay'?: string;
"--circle-move-x"?: string;
"--circle-move-y"?: string;
"--circle-duration"?: string;
"--circle-delay"?: string;
};
interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundProps {
@@ -14,11 +14,7 @@ interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundP
slideKey: string;
}
export default function AnimatedSlideBackground({
gradientStops,
circles,
isActive,
}: AnimatedSlideBackgroundComponentProps) {
export default function AnimatedSlideBackground({ gradientStops, circles, isActive }: AnimatedSlideBackgroundComponentProps) {
const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null);
const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops);
const [isTransitioning, setIsTransitioning] = React.useState(false);
@@ -31,13 +27,13 @@ export default function AnimatedSlideBackground({
setCurrentGradient(gradientStops);
return;
}
// Only transition if gradient actually changed
if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) {
// Store previous gradient and start transition
setPrevGradient(currentGradient);
setIsTransitioning(true);
// Update to new gradient (will fade in)
setCurrentGradient(gradientStops);
}
@@ -59,8 +55,8 @@ export default function AnimatedSlideBackground({
return (
<div className={styles.hero} key="animated-background">
{prevGradientStyle && isTransitioning && (
<div
className={`${styles.gradientLayer} ${styles.gradientLayerPrevFadeOut}`}
<div
className={`${styles.gradientLayer} ${styles.gradientLayerPrevFadeOut}`}
style={prevGradientStyle}
onTransitionEnd={() => {
setPrevGradient(null);
@@ -69,14 +65,14 @@ export default function AnimatedSlideBackground({
/>
)}
<div
className={`${styles.gradientLayer} ${isActive ? styles.gradientLayerActive : ''}`.trim()}
className={`${styles.gradientLayer} ${isActive ? styles.gradientLayerActive : ""}`.trim()}
style={currentGradientStyle}
/>
{circles.map((circle, index) => {
const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle;
const moveX = position === 'bottom-left' ? amplitude : -amplitude;
const moveY = position === 'bottom-left' ? -amplitude * 0.6 : amplitude * 0.6;
const moveX = position === "bottom-left" ? amplitude : -amplitude;
const moveY = position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6;
const circleStyle: CircleStyles = {
width: size,
@@ -84,17 +80,17 @@ export default function AnimatedSlideBackground({
background: color,
opacity: opacity ?? 0.9,
filter: blur ? `blur(${blur}px)` : undefined,
'--circle-move-x': `${moveX}px`,
'--circle-move-y': `${moveY}px`,
'--circle-duration': `${duration}s`,
'--circle-delay': `${delay}s`,
"--circle-move-x": `${moveX}px`,
"--circle-move-y": `${moveY}px`,
"--circle-duration": `${duration}s`,
"--circle-delay": `${delay}s`,
};
const defaultOffset = -size / 2;
const offsetX = circle.offsetX ?? 0;
const offsetY = circle.offsetY ?? 0;
if (position === 'bottom-left') {
if (position === "bottom-left") {
circleStyle.left = `${defaultOffset + offsetX}px`;
circleStyle.bottom = `${defaultOffset + offsetY}px`;
} else {
@@ -102,13 +98,7 @@ export default function AnimatedSlideBackground({
circleStyle.top = `${defaultOffset + offsetY}px`;
}
return (
<div
key={`circle-${index}-${position}`}
className={styles.circle}
style={circleStyle}
/>
);
return <div key={`circle-${index}-${position}`} className={styles.circle} style={circleStyle} />;
})}
</div>
);
@@ -1,8 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SlideConfig } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import { DesktopInstallTitle, type OSOption } from '@app/components/onboarding/slides/DesktopInstallTitle';
import React from "react";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { DesktopInstallTitle, type OSOption } from "@app/components/onboarding/slides/DesktopInstallTitle";
export type { OSOption };
@@ -19,8 +19,8 @@ const DesktopInstallBody = () => {
return (
<span>
{t(
'onboarding.desktopInstall.body',
'Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.',
"onboarding.desktopInstall.body",
"Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.",
)}
</span>
);
@@ -32,11 +32,10 @@ export default function DesktopInstallSlide({
osOptions = [],
onDownloadUrlChange,
}: DesktopInstallSlideProps): SlideConfig {
return {
key: 'desktop-install',
key: "desktop-install",
title: (
<DesktopInstallTitle
<DesktopInstallTitle
osLabel={osLabel}
osUrl={osUrl}
osOptions={osOptions || []}
@@ -46,9 +45,8 @@ export default function DesktopInstallSlide({
body: <DesktopInstallBody />,
downloadUrl: osUrl,
background: {
gradientStops: ['#2563EB', '#0EA5E9'],
gradientStops: ["#2563EB", "#0EA5E9"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Menu, ActionIcon } from '@mantine/core';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import React from "react";
import { useTranslation } from "react-i18next";
import { Menu, ActionIcon } from "@mantine/core";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
export interface OSOption {
label: string;
@@ -16,11 +16,11 @@ interface DesktopInstallTitleProps {
onDownloadUrlChange?: (url: string) => void;
}
export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
osLabel,
osUrl,
osOptions,
onDownloadUrlChange
export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
osLabel,
osUrl,
osOptions,
onDownloadUrlChange,
}) => {
const { t } = useTranslation();
const [selectedOsUrl, setSelectedOsUrl] = React.useState<string>(osUrl);
@@ -29,37 +29,41 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
setSelectedOsUrl(osUrl);
}, [osUrl]);
const handleOsSelect = React.useCallback((option: OSOption) => {
setSelectedOsUrl(option.url);
onDownloadUrlChange?.(option.url);
}, [onDownloadUrlChange]);
const handleOsSelect = React.useCallback(
(option: OSOption) => {
setSelectedOsUrl(option.url);
onDownloadUrlChange?.(option.url);
},
[onDownloadUrlChange],
);
const currentOsOption = osOptions.find(opt => opt.url === selectedOsUrl) ||
const currentOsOption =
osOptions.find((opt) => opt.url === selectedOsUrl) ||
(osOptions.length > 0 ? osOptions[0] : { label: osLabel, url: osUrl });
const displayLabel = currentOsOption.label || osLabel;
const title = displayLabel
? t('onboarding.desktopInstall.titleWithOs', 'Download for {{osLabel}}', { osLabel: displayLabel })
: t('onboarding.desktopInstall.title', 'Download');
const title = displayLabel
? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", { osLabel: displayLabel })
: t("onboarding.desktopInstall.title", "Download");
// If only one option or no options, don't show dropdown
if (osOptions.length <= 1) {
return <div style={{ textAlign: 'center', width: '100%' }}>{title}</div>;
return <div style={{ textAlign: "center", width: "100%" }}>{title}</div>;
}
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', width: '100%' }}>
<span style={{ whiteSpace: 'nowrap' }}>{title}</span>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "0.5rem", width: "100%" }}>
<span style={{ whiteSpace: "nowrap" }}>{title}</span>
<Menu position="bottom" offset={5} zIndex={10000}>
<Menu.Target>
<ActionIcon
variant="transparent"
size="sm"
style={{
background: 'transparent',
border: 'none',
color: 'inherit',
padding: 0
style={{
background: "transparent",
border: "none",
color: "inherit",
padding: 0,
}}
>
<ExpandMoreIcon fontSize="small" />
@@ -74,11 +78,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
onClick={() => handleOsSelect(option)}
style={{
backgroundColor: isSelected
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
: 'transparent',
color: isSelected
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
: 'inherit',
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
: "transparent",
color: isSelected ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" : "inherit",
}}
>
{option.label}
@@ -90,4 +92,3 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
</div>
);
};
@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import { Stack, PasswordInput, Button, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { SlideConfig } from '@app/types/types';
import LocalIcon from '@app/components/shared/LocalIcon';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import { accountService } from '@app/services/accountService';
import { alert as showToast } from '@app/components/toast';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import React, { useState } from "react";
import { Stack, PasswordInput, Button, Alert, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import LocalIcon from "@app/components/shared/LocalIcon";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { accountService } from "@app/services/accountService";
import { alert as showToast } from "@app/components/toast";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
interface FirstLoginSlideProps {
username: string;
@@ -14,66 +14,66 @@ interface FirstLoginSlideProps {
usingDefaultCredentials?: boolean;
}
const DEFAULT_PASSWORD = 'stirling';
const DEFAULT_PASSWORD = "stirling";
function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) {
const { t } = useTranslation();
// If using default credentials, pre-fill with "stirling" - user won't see this field
const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : '');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : "");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState("");
const handleSubmit = async () => {
// Validation
if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) {
setError(t('firstLogin.allFieldsRequired', 'All fields are required'));
setError(t("firstLogin.allFieldsRequired", "All fields are required"));
return;
}
if (newPassword !== confirmPassword) {
setError(t('firstLogin.passwordsDoNotMatch', 'New passwords do not match'));
setError(t("firstLogin.passwordsDoNotMatch", "New passwords do not match"));
return;
}
if (newPassword.length < 8) {
setError(t('firstLogin.passwordTooShort', 'Password must be at least 8 characters'));
setError(t("firstLogin.passwordTooShort", "Password must be at least 8 characters"));
return;
}
if (newPassword === currentPassword) {
setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password'));
setError(t("firstLogin.passwordMustBeDifferent", "New password must be different from current password"));
return;
}
try {
setLoading(true);
setError('');
setError("");
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
showToast({
alertType: 'success',
title: t('firstLogin.passwordChangedSuccess', 'Password changed successfully! Please log in again.')
alertType: "success",
title: t("firstLogin.passwordChangedSuccess", "Password changed successfully! Please log in again."),
});
// Clear form
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
// Wait a moment for the user to see the success message
setTimeout(() => {
onPasswordChanged();
}, 1500);
} catch (err) {
console.error('Failed to change password:', err);
console.error("Failed to change password:", err);
// Extract error message from axios response if available
const axiosError = err as { response?: { data?: { message?: string } } };
setError(
axiosError.response?.data?.message ||
t('firstLogin.passwordChangeFailed', 'Failed to change password. Please check your current password.')
t("firstLogin.passwordChangeFailed", "Failed to change password. Please check your current password."),
);
} finally {
setLoading(false);
@@ -85,25 +85,18 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
<div className={styles.securityCard}>
<Stack gap="md">
<div className={styles.securityAlertRow}>
<LocalIcon icon="info-rounded" width={20} height={20} style={{ color: '#3B82F6', flexShrink: 0 }} />
<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.'
)}
{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>
{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"
>
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
{error}
</Alert>
)}
@@ -111,8 +104,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
{/* 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')}
label={t("firstLogin.currentPassword", "Current Password")}
placeholder={t("firstLogin.enterCurrentPassword", "Enter your current password")}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
@@ -123,8 +116,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
)}
<PasswordInput
label={t('firstLogin.newPassword', 'New Password')}
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
label={t("firstLogin.newPassword", "New Password")}
placeholder={t("firstLogin.enterNewPassword", "Enter new password (min 8 characters)")}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8}
@@ -135,8 +128,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
/>
<PasswordInput
label={t('firstLogin.confirmPassword', 'Confirm New Password')}
placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')}
label={t("firstLogin.confirmPassword", "Confirm New Password")}
placeholder={t("firstLogin.reEnterNewPassword", "Re-enter new password")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
@@ -154,7 +147,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
size="md"
mt="xs"
>
{t('firstLogin.changePassword', 'Change Password')}
{t("firstLogin.changePassword", "Change Password")}
</Button>
</Stack>
</div>
@@ -168,8 +161,8 @@ export default function FirstLoginSlide({
usingDefaultCredentials = false,
}: FirstLoginSlideProps): SlideConfig {
return {
key: 'first-login',
title: 'Set Your Password',
key: "first-login",
title: "Set Your Password",
body: (
<FirstLoginForm
username={username}
@@ -178,9 +171,8 @@ export default function FirstLoginSlide({
/>
),
background: {
gradientStops: ['#059669', '#0891B2'], // Green to teal - security/trust colors
gradientStops: ["#059669", "#0891B2"], // Green to teal - security/trust colors
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -4,7 +4,7 @@ import { QRCodeSVG } from "qrcode.react";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { accountService } from "@app/services/accountService";
import { useAccountLogout } from '@app/extensions/accountLogout';
import { useAccountLogout } from "@app/extensions/accountLogout";
import { useAuth } from "@app/auth/UseSession";
import LocalIcon from "@app/components/shared/LocalIcon";
import { BASE_PATH } from "@app/constants/app";
@@ -59,10 +59,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
}, [fetchMfaSetup]);
const redirectToLogin = useCallback(() => {
window.location.assign('/login');
window.location.assign("/login");
}, []);
const onLogout = useCallback(async() => {
const onLogout = useCallback(async () => {
await accountLogout({ signOut, redirectToLogin });
}, [accountLogout, redirectToLogin, signOut]);
@@ -84,13 +84,13 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error || "Unable to enable two-factor authentication. Check the code and try again."
axiosError.response?.data?.error || "Unable to enable two-factor authentication. Check the code and try again.",
);
} finally {
setSubmitting(false);
}
},
[mfaSetupCode, onMfaSetupComplete]
[mfaSetupCode, onMfaSetupComplete],
);
const isReady = Boolean(mfaSetupData);
@@ -179,18 +179,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
>
Regenerate QR code
</Button>
<Button
type="submit"
loading={submitting}
disabled={!isReady || setupComplete || mfaSetupCode.length < 6}
>
<Button type="submit" loading={submitting} disabled={!isReady || setupComplete || mfaSetupCode.length < 6}>
Enable MFA
</Button>
<Button
type="button"
variant="light"
onClick={onLogout}
>
<Button type="button" variant="light" onClick={onLogout}>
Logout
</Button>
</Group>
@@ -1,7 +1,7 @@
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { SlideConfig, LicenseNotice } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import { SlideConfig, LicenseNotice } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
interface PlanOverviewSlideProps {
isAdmin: boolean;
@@ -16,31 +16,23 @@ const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => {
return (
<>
{isAdmin
? t('onboarding.planOverview.adminTitle', 'Admin Overview')
: t('onboarding.planOverview.userTitle', 'Plan Overview')}
? t("onboarding.planOverview.adminTitle", "Admin Overview")
: t("onboarding.planOverview.userTitle", "Plan Overview")}
</>
);
};
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({
freeTierLimit,
loginEnabled,
}) => {
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({ freeTierLimit, loginEnabled }) => {
const adminBodyKey = loginEnabled
? 'onboarding.planOverview.adminBodyLoginEnabled'
: 'onboarding.planOverview.adminBodyLoginDisabled';
? "onboarding.planOverview.adminBodyLoginEnabled"
: "onboarding.planOverview.adminBodyLoginDisabled";
const defaultValue = loginEnabled
? 'As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.'
: 'Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.';
? "As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge."
: "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.";
return (
<Trans
i18nKey={adminBodyKey}
values={{ freeTierLimit }}
components={{ strong: <strong /> }}
defaults={defaultValue}
/>
<Trans i18nKey={adminBodyKey} values={{ freeTierLimit }} components={{ strong: <strong /> }} defaults={defaultValue} />
);
};
@@ -49,7 +41,7 @@ const UserOverviewBody: React.FC = () => {
return (
<span>
{t(
'onboarding.planOverview.userBody',
"onboarding.planOverview.userBody",
"Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use.",
)}
</span>
@@ -60,8 +52,7 @@ const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; logi
isAdmin,
freeTierLimit,
loginEnabled,
}) =>
isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />;
}) => (isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />);
export default function PlanOverviewSlide({
isAdmin,
@@ -71,13 +62,12 @@ export default function PlanOverviewSlide({
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
return {
key: isAdmin ? 'admin-overview' : 'plan-overview',
key: isAdmin ? "admin-overview" : "plan-overview",
title: <PlanOverviewTitle isAdmin={isAdmin} />,
body: <PlanOverviewBody isAdmin={isAdmin} freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} />,
background: {
gradientStops: isAdmin ? ['#4F46E5', '#0EA5E9'] : ['#F97316', '#EF4444'],
gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,39 +1,41 @@
import React from 'react';
import { Select } from '@mantine/core';
import { SlideConfig } from '@app/types/types';
import LocalIcon from '@app/components/shared/LocalIcon';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import i18n from '@app/i18n';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import React from "react";
import { Select } from "@mantine/core";
import { SlideConfig } from "@app/types/types";
import LocalIcon from "@app/components/shared/LocalIcon";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import i18n from "@app/i18n";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
interface SecurityCheckSlideProps {
selectedRole: 'admin' | 'user' | null;
onRoleSelect: (role: 'admin' | 'user' | null) => void;
selectedRole: "admin" | "user" | null;
onRoleSelect: (role: "admin" | "user" | null) => void;
}
export default function SecurityCheckSlide({
selectedRole,
onRoleSelect,
}: SecurityCheckSlideProps): SlideConfig {
export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: SecurityCheckSlideProps): SlideConfig {
return {
key: 'security-check',
title: 'Security Check',
key: "security-check",
title: "Security Check",
body: (
<div className={styles.securitySlideContent}>
<div className={styles.securityCard}>
<div className={styles.securityAlertRow}>
<LocalIcon icon="error" width={20} height={20} style={{ color: '#F04438', flexShrink: 0 }} />
<span>{i18n.t('onboarding.securityCheck.message', 'The application has undergone significant changes recently. Your server admin\'s attention may be required. Please confirm your role to continue.')}</span>
<LocalIcon icon="error" width={20} height={20} style={{ color: "#F04438", flexShrink: 0 }} />
<span>
{i18n.t(
"onboarding.securityCheck.message",
"The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue.",
)}
</span>
</div>
<Select
placeholder="Confirm your role"
value={selectedRole}
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
{ value: "admin", label: "Admin" },
{ value: "user", label: "User" },
]}
onChange={(value) => onRoleSelect((value as 'admin' | 'user') ?? null)}
onChange={(value) => onRoleSelect((value as "admin" | "user") ?? null)}
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
styles={{
input: {
@@ -46,10 +48,8 @@ export default function SecurityCheckSlide({
</div>
),
background: {
gradientStops: ['#5B21B6', '#2563EB'],
gradientStops: ["#5B21B6", "#2563EB"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,8 +1,8 @@
import React from 'react';
import { Trans } from 'react-i18next';
import { SlideConfig, LicenseNotice } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import i18n from '@app/i18n';
import React from "react";
import { Trans } from "react-i18next";
import { SlideConfig, LicenseNotice } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import i18n from "@app/i18n";
interface ServerLicenseSlideProps {
licenseNotice?: LicenseNotice;
@@ -17,9 +17,9 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide
const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
const title = isOverLimit
? i18n.t('onboarding.serverLicense.overLimitTitle', 'Server License Needed')
: i18n.t('onboarding.serverLicense.freeTitle', 'Server License');
const key = isOverLimit ? 'server-license-over-limit' : 'server-license';
? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed")
: i18n.t("onboarding.serverLicense.freeTitle", "Server License");
const key = isOverLimit ? "server-license-over-limit" : "server-license";
const overLimitBody = (
<Trans
@@ -50,10 +50,8 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide
title,
body,
background: {
gradientStops: isOverLimit ? ['#F472B6', '#8B5CF6'] : ['#F97316', '#F59E0B'],
gradientStops: isOverLimit ? ["#F472B6", "#8B5CF6"] : ["#F97316", "#F59E0B"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,14 +1,14 @@
import React from 'react';
import { Trans } from 'react-i18next';
import i18n from '@app/i18n';
import { SlideConfig } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import React from "react";
import { Trans } from "react-i18next";
import i18n from "@app/i18n";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
export default function TourOverviewSlide(): SlideConfig {
return {
key: 'tour-overview',
title: i18n.t('onboarding.tourOverview.title', 'Tour Overview'),
key: "tour-overview",
title: i18n.t("onboarding.tourOverview.title", "Tour Overview"),
body: (
<span className={styles.bodyCopyInner}>
<Trans
@@ -19,9 +19,8 @@ export default function TourOverviewSlide(): SlideConfig {
</span>
),
background: {
gradientStops: ['#2563EB', '#7C3AED'],
gradientStops: ["#2563EB", "#7C3AED"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,15 +1,15 @@
import React from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { SlideConfig } from '@app/types/types';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import React from "react";
import { useTranslation, Trans } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
function WelcomeSlideTitle() {
const { t } = useTranslation();
return (
<span className={styles.welcomeTitleContainer}>
{t('onboarding.welcomeSlide.title', 'Welcome to Stirling')}
{t("onboarding.welcomeSlide.title", "Welcome to Stirling")}
<span className={styles.v2Badge}>V2</span>
</span>
);
@@ -27,13 +27,12 @@ const WelcomeSlideBody = () => (
export default function WelcomeSlide(): SlideConfig {
return {
key: 'welcome',
key: "welcome",
title: <WelcomeSlideTitle />,
body: <WelcomeSlideBody />,
background: {
gradientStops: ['#7C3AED', '#EC4899'],
gradientStops: ["#7C3AED", "#EC4899"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,4 +1,4 @@
import { AnimatedCircleConfig } from '@app/types/types';
import { AnimatedCircleConfig } from "@app/types/types";
/**
* Unified circle background configuration used across all onboarding slides.
@@ -6,9 +6,9 @@ import { AnimatedCircleConfig } from '@app/types/types';
*/
export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
{
position: 'bottom-left',
position: "bottom-left",
size: 270,
color: 'rgba(255, 255, 255, 0.25)',
color: "rgba(255, 255, 255, 0.25)",
opacity: 0.9,
amplitude: 24,
duration: 4.5,
@@ -16,9 +16,9 @@ export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
offsetY: 14,
},
{
position: 'top-right',
position: "top-right",
size: 300,
color: 'rgba(255, 255, 255, 0.2)',
color: "rgba(255, 255, 255, 0.2)",
opacity: 0.9,
amplitude: 28,
duration: 4.5,
@@ -27,4 +27,3 @@ export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
offsetY: 18,
},
];
@@ -3,16 +3,15 @@ export const addGlowToElements = (selectors: string[]) => {
const element = document.querySelector(selector);
if (element) {
if (selector === '[data-tour="settings-content-area"]') {
element.classList.add('tour-content-glow');
element.classList.add("tour-content-glow");
} else {
element.classList.add('tour-nav-glow');
element.classList.add("tour-nav-glow");
}
}
});
};
export const removeAllGlows = () => {
document.querySelectorAll('.tour-content-glow').forEach((el) => el.classList.remove('tour-content-glow'));
document.querySelectorAll('.tour-nav-glow').forEach((el) => el.classList.remove('tour-nav-glow'));
document.querySelectorAll(".tour-content-glow").forEach((el) => el.classList.remove("tour-content-glow"));
document.querySelectorAll(".tour-nav-glow").forEach((el) => el.classList.remove("tour-nav-glow"));
};
@@ -1,28 +1,28 @@
import { useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { markOnboardingCompleted } from '@app/components/onboarding/orchestrator/onboardingStorage';
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { markOnboardingCompleted } from "@app/components/onboarding/orchestrator/onboardingStorage";
const SESSION_KEY = 'onboarding::bypass-all';
const PARAM_KEY = 'bypassOnboarding';
const SESSION_KEY = "onboarding::bypass-all";
const PARAM_KEY = "bypassOnboarding";
function isTruthy(value: string | null): boolean {
return value?.toLowerCase() === 'true';
return value?.toLowerCase() === "true";
}
function readStoredBypass(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window === "undefined") return false;
try {
return sessionStorage.getItem(SESSION_KEY) === 'true';
return sessionStorage.getItem(SESSION_KEY) === "true";
} catch {
return false;
}
}
function setStoredBypass(enabled: boolean): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
if (enabled) {
sessionStorage.setItem(SESSION_KEY, 'true');
sessionStorage.setItem(SESSION_KEY, "true");
} else {
sessionStorage.removeItem(SESSION_KEY);
}
@@ -1,12 +1,12 @@
/**
* 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';
import { useState, useEffect, useMemo, useCallback } from "react";
import { useOs } from "@app/hooks/useOs";
import { DOWNLOAD_URLS } from "@app/constants/downloads";
interface OsInfo {
label: string;
@@ -29,30 +29,34 @@ interface UseOnboardingDownloadResult {
export function useOnboardingDownload(): UseOnboardingDownloadResult {
const osType = useOs();
const [selectedDownloadUrl, setSelectedDownloadUrl] = useState<string>('');
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 };
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: '' };
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), []);
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(() => {
@@ -64,7 +68,7 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
const handleDownloadSelected = useCallback(() => {
const downloadUrl = selectedDownloadUrl || osInfo.url;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener');
window.open(downloadUrl, "_blank", "noopener");
}
}, [selectedDownloadUrl, osInfo.url]);
@@ -76,4 +80,3 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
handleDownloadSelected,
};
}
@@ -1,27 +1,27 @@
import { useEffect, useCallback, useState } from 'react';
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';
} from "@app/constants/events";
import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig";
export function useServerLicenseRequest(): {
showLicenseSlide: boolean;
licenseNotice: OnboardingRuntimeState['licenseNotice'] | null;
licenseNotice: OnboardingRuntimeState["licenseNotice"] | null;
closeLicenseSlide: () => void;
} {
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState['licenseNotice'] | null>(null);
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState["licenseNotice"] | null>(null);
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
const handleLicenseRequest = (event: Event) => {
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
if (detail?.licenseNotice) {
setLicenseNotice({
totalUsers: detail.licenseNotice.totalUsers ?? null,
@@ -30,7 +30,7 @@ export function useServerLicenseRequest(): {
requiresLicense: true,
});
}
setShowLicenseSlide(true);
};
@@ -51,14 +51,14 @@ export function useTourRequest(): {
clearTourRequest: () => void;
} {
const [tourRequested, setTourRequested] = useState(false);
const [requestedTourType, setRequestedTourType] = useState<TourType>('whatsnew');
const [requestedTourType, setRequestedTourType] = useState<TourType>("whatsnew");
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
const handleTourRequest = (event: Event) => {
const { detail } = event as CustomEvent<StartTourPayload>;
setRequestedTourType(detail?.tourType ?? 'whatsnew');
setRequestedTourType(detail?.tourType ?? "whatsnew");
setTourRequested(true);
};
@@ -1,5 +1,5 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next";
export enum TourStep {
ALL_TOOLS,
@@ -53,8 +53,11 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
return {
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
position: 'center',
content: t(
"onboarding.allTools",
"This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.",
),
position: "center",
padding: 0,
action: () => {
saveWorkbenchState();
@@ -64,28 +67,40 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
},
[TourStep.SELECT_CROP_TOOL]: {
selector: '[data-tour="tool-button-crop"]',
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
position: 'right',
content: t(
"onboarding.selectCropTool",
"Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
),
position: "right",
padding: 0,
actionAfter: () => selectCropTool(),
},
[TourStep.TOOL_INTERFACE]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
position: 'center',
content: t(
"onboarding.toolInterface",
"This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
),
position: "center",
padding: 0,
},
[TourStep.FILES_BUTTON]: {
selector: '[data-tour="files-button"]',
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
position: 'right',
content: t(
"onboarding.filesButton",
"The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
),
position: "right",
padding: 10,
action: () => openFilesModal(),
},
[TourStep.FILE_SOURCES]: {
selector: '[data-tour="file-sources"]',
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
position: 'right',
content: t(
"onboarding.fileSources",
"You can upload new files or access recent files from here. For the tour, we'll just use a sample file.",
),
position: "right",
padding: 0,
actionAfter: () => {
loadSampleFile();
@@ -94,62 +109,88 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
},
[TourStep.WORKBENCH]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
position: 'center',
content: t(
"onboarding.workbench",
"This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.",
),
position: "center",
padding: 0,
},
[TourStep.ACTIVE_FILES]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
position: 'center',
content: t(
"onboarding.activeFiles",
"The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process.",
),
position: "center",
padding: 0,
action: () => switchToActiveFiles(),
},
[TourStep.FILE_CHECKBOX]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
position: 'top',
content: t(
"onboarding.fileCheckbox",
"Clicking one of the files selects it for processing. You can select multiple files for batch operations.",
),
position: "top",
padding: 10,
},
[TourStep.CROP_SETTINGS]: {
selector: '[data-tour="crop-settings"]',
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
position: 'left',
content: t(
"onboarding.cropSettings",
"Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to.",
),
position: "left",
padding: 10,
action: () => modifyCropSettings(),
},
[TourStep.RUN_BUTTON]: {
selector: '[data-tour="run-button"]',
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
position: 'top',
content: t(
"onboarding.runButton",
"Once the tool has been configured, this button allows you to run the tool on all the selected PDFs.",
),
position: "top",
padding: 10,
actionAfter: () => executeTool(),
},
[TourStep.RESULTS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
position: 'center',
content: t(
"onboarding.results",
"After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. ",
),
position: "center",
padding: 0,
},
[TourStep.FILE_REPLACEMENT]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
position: 'left',
content: t(
"onboarding.fileReplacement",
"The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools.",
),
position: "left",
padding: 10,
},
[TourStep.PIN_BUTTON]: {
selector: '[data-tour="file-card-pin"]',
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
position: 'left',
content: t(
"onboarding.pinButton",
"You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them.",
),
position: "left",
padding: 10,
action: () => pinFile(),
},
[TourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
position: 'right',
content: t(
"onboarding.wrapUp",
"You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again.",
),
position: "right",
padding: 10,
},
};
}
@@ -1,8 +1,8 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next";
async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 100): Promise<void> {
if (typeof document === 'undefined') return;
if (typeof document === "undefined") return;
const start = Date.now();
// Immediate hit
if (document.querySelector(selector)) return;
@@ -20,7 +20,7 @@ async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 1
}
async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise<void> {
if (typeof document === 'undefined') return;
if (typeof document === "undefined") return;
const start = Date.now();
return new Promise((resolve) => {
@@ -29,8 +29,8 @@ async function waitForHighlightable(selector: string, timeoutMs = 7000, interval
const isVisible = !!el && el.getClientRects().length > 0;
if (isVisible || Date.now() - start >= timeoutMs) {
// Nudge Reactour to recalc positions in case layout shifted
window.dispatchEvent(new Event('resize'));
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
window.dispatchEvent(new Event("resize"));
requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
resolve();
return;
}
@@ -85,10 +85,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
[WhatsNewTourStep.QUICK_ACCESS]: {
selector: '[data-tour="quick-access-bar"]',
content: t(
'onboarding.whatsNew.quickAccess',
'Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours.'
"onboarding.whatsNew.quickAccess",
"Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours.",
),
position: 'right',
position: "right",
padding: 10,
action: () => {
saveWorkbenchState();
@@ -99,19 +99,19 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
[WhatsNewTourStep.LEFT_PANEL]: {
selector: '[data-tour="tool-panel"]',
content: t(
'onboarding.whatsNew.leftPanel',
'The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly.'
"onboarding.whatsNew.leftPanel",
"The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly.",
),
position: 'center',
position: "center",
padding: 0,
},
[WhatsNewTourStep.FILE_UPLOAD]: {
selector: '[data-tour="files-button"]',
content: t(
'onboarding.whatsNew.fileUpload',
'Use the <strong>Files</strong> button to upload or pick a recent PDF. We will load a sample so you can see the workspace.'
"onboarding.whatsNew.fileUpload",
"Use the <strong>Files</strong> button to upload or pick a recent PDF. We will load a sample so you can see the workspace.",
),
position: 'right',
position: "right",
padding: 10,
action: async () => {
openFilesModal();
@@ -130,10 +130,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
selector: '[data-tour="right-rail-controls"]',
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
content: t(
'onboarding.whatsNew.rightRail',
'The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.'
"onboarding.whatsNew.rightRail",
"The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.",
),
position: 'left',
position: "left",
padding: 10,
action: async () => {
await waitForElement('[data-tour="right-rail-controls"]', 7000, 100);
@@ -143,10 +143,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
[WhatsNewTourStep.TOP_BAR]: {
selector: '[data-tour="view-switcher"]',
content: t(
'onboarding.whatsNew.topBar',
'The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>.'
"onboarding.whatsNew.topBar",
"The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>.",
),
position: 'bottom',
position: "bottom",
padding: 8,
// Ensure the switcher has mounted before this step renders
action: async () => {
@@ -157,11 +157,8 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
},
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
selector: '[data-tour="view-switcher"]',
content: t(
'onboarding.whatsNew.pageEditorView',
'Switch to the Page Editor to reorder, rotate, or delete pages.'
),
position: 'bottom',
content: t("onboarding.whatsNew.pageEditorView", "Switch to the Page Editor to reorder, rotate, or delete pages."),
position: "bottom",
padding: 8,
action: async () => {
switchToPageEditor();
@@ -172,10 +169,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
[WhatsNewTourStep.ACTIVE_FILES_VIEW]: {
selector: '[data-tour="view-switcher"]',
content: t(
'onboarding.whatsNew.activeFilesView',
'Use Active Files to see everything you have open and pick what to work on.'
"onboarding.whatsNew.activeFilesView",
"Use Active Files to see everything you have open and pick what to work on.",
),
position: 'bottom',
position: "bottom",
padding: 8,
action: async () => {
switchToActiveFiles();
@@ -186,12 +183,11 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
[WhatsNewTourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t(
'onboarding.whatsNew.wrapUp',
'That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour.'
"onboarding.whatsNew.wrapUp",
"That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour.",
),
position: 'right',
position: "right",
padding: 10,
},
};
}
@@ -1,9 +1,9 @@
import { useState } from 'react';
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
import PageSelectionInput from '@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput';
import SelectedPagesDisplay from '@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay';
import PageSelectionSyntaxHint from '@app/components/shared/PageSelectionSyntaxHint';
import AdvancedSelectionPanel from '@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel';
import { useState } from "react";
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
import PageSelectionInput from "@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput";
import SelectedPagesDisplay from "@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay";
import PageSelectionSyntaxHint from "@app/components/shared/PageSelectionSyntaxHint";
import AdvancedSelectionPanel from "@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel";
interface BulkSelectionPanelProps {
csvInput: string;
@@ -24,8 +24,8 @@ const BulkSelectionPanel = ({
const maxPages = displayDocument?.pages?.length ?? 0;
const handleClear = () => {
setCsvInput('');
onUpdatePagesFromCSV('');
setCsvInput("");
onUpdatePagesFromCSV("");
};
return (
@@ -41,11 +41,7 @@ const BulkSelectionPanel = ({
<PageSelectionSyntaxHint input={csvInput} maxPages={maxPages} variant="panel" />
<SelectedPagesDisplay
selectedPageIds={selectedPageIds}
displayDocument={displayDocument}
syntaxError={null}
/>
<SelectedPagesDisplay selectedPageIds={selectedPageIds} displayDocument={displayDocument} syntaxError={null} />
<AdvancedSelectionPanel
csvInput={csvInput}
@@ -58,4 +54,4 @@ const BulkSelectionPanel = ({
);
};
export default BulkSelectionPanel;
export default BulkSelectionPanel;
@@ -1,14 +1,10 @@
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
import styles from '@app/components/pageEditor/DragDropGrid.module.css';
import {
Z_INDEX_SELECTION_BOX,
Z_INDEX_DROP_INDICATOR,
Z_INDEX_DRAG_BADGE,
} from '@app/styles/zIndex';
import { LocalIcon } from '@app/components/shared/LocalIcon';
import React, { useRef, useEffect, useState, useCallback, useMemo } from "react";
import { Box } from "@mantine/core";
import { useVirtualizer } from "@tanstack/react-virtual";
import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
import styles from "@app/components/pageEditor/DragDropGrid.module.css";
import { Z_INDEX_SELECTION_BOX, Z_INDEX_DROP_INDICATOR, Z_INDEX_DRAG_BADGE } from "@app/styles/zIndex";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import {
DndContext,
DragEndEvent,
@@ -20,7 +16,7 @@ import {
closestCenter,
useDraggable,
useDroppable,
} from '@dnd-kit/core';
} from "@dnd-kit/core";
interface DragDropItem {
id: string;
@@ -33,7 +29,17 @@ interface DragDropItem {
interface DragDropGridProps<T extends DragDropItem> {
items: T[];
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, activeDragIds: string[], justMoved: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
renderItem: (
item: T,
index: number,
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
boxSelectedIds: string[],
clearBoxSelection: () => void,
activeDragIds: string[],
justMoved: boolean,
dragHandleProps?: any,
zoomLevel?: number,
) => React.ReactNode;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
zoomLevel?: number;
selectedFileIds?: string[];
@@ -41,7 +47,7 @@ interface DragDropGridProps<T extends DragDropItem> {
onVisibleItemsChange?: (items: T[]) => void;
}
type DropSide = 'left' | 'right' | null;
type DropSide = "left" | "right" | null;
type ItemRect = { id: string; rect: DOMRect };
@@ -131,13 +137,13 @@ function resolveDropHint(
let dropSide: DropSide;
if (cursorX < firstItem.rect.left) {
hoveredItem = firstItem;
dropSide = 'left';
dropSide = "left";
} else if (cursorX > lastItem.rect.right) {
hoveredItem = lastItem;
dropSide = 'right';
dropSide = "right";
} else {
const midpoint = hoveredItem.rect.left + hoveredItem.rect.width / 2;
dropSide = cursorX >= midpoint ? 'right' : 'left';
dropSide = cursorX >= midpoint ? "right" : "left";
}
return { hoveredId: hoveredItem.id, dropSide };
@@ -168,15 +174,15 @@ function resolveTargetIndex<T extends DragDropItem>(
};
if (hoveredId) {
const filteredIndex = filteredItems.findIndex(item => item.id === hoveredId);
const filteredIndex = filteredItems.findIndex((item) => item.id === hoveredId);
if (filteredIndex !== -1) {
const adjustedIndex = filteredIndex + (dropSide === 'right' ? 1 : 0);
const adjustedIndex = filteredIndex + (dropSide === "right" ? 1 : 0);
return convertFilteredIndexToOriginal(adjustedIndex);
}
}
if (fallbackIndex !== null && fallbackIndex !== undefined) {
const adjustedIndex = fallbackIndex + (dropSide === 'right' ? 1 : 0);
const adjustedIndex = fallbackIndex + (dropSide === "right" ? 1 : 0);
return convertFilteredIndexToOriginal(adjustedIndex);
}
@@ -194,15 +200,41 @@ interface DraggableItemProps<T extends DragDropItem> {
justMoved: boolean;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
onUpdateDropTarget: (itemId: string | null) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, activeDragIds: string[], justMoved: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
renderItem: (
item: T,
index: number,
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
boxSelectedIds: string[],
clearBoxSelection: () => void,
activeDragIds: string[],
justMoved: boolean,
dragHandleProps?: any,
zoomLevel?: number,
) => React.ReactNode;
zoomLevel: number;
selectedPageIds?: string[];
}
const DraggableItemInner = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
const DraggableItemInner = <T extends DragDropItem>({
item,
index,
itemRefs,
boxSelectedPageIds,
clearBoxSelection,
activeDragIds,
justMoved,
getThumbnailData,
renderItem,
onUpdateDropTarget,
zoomLevel,
}: DraggableItemProps<T>) => {
const isPlaceholder = Boolean(item.isPlaceholder);
const pageNumber = (item as any).pageNumber ?? index + 1;
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
} = useDraggable({
id: item.id,
disabled: isPlaceholder,
data: {
@@ -215,21 +247,21 @@ const DraggableItemInner = <T extends DragDropItem>({ item, index, itemRefs, box
}
const element = itemRefs.current.get(item.id);
const imgElement = element?.querySelector('img.ph-no-capture') as HTMLImageElement;
const imgElement = element?.querySelector("img.ph-no-capture") as HTMLImageElement;
if (imgElement?.src) {
return {
src: imgElement.src,
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0,
};
}
return null;
}
}
},
},
});
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: item.id,
data: { index, pageNumber: index + 1 }
data: { index, pageNumber: index + 1 },
});
// Notify parent when hover state changes
@@ -241,14 +273,27 @@ const DraggableItemInner = <T extends DragDropItem>({ item, index, itemRefs, box
}
}, [isOver, item.id, onUpdateDropTarget]);
const setNodeRef = useCallback((element: HTMLDivElement | null) => {
setDraggableRef(element);
setDroppableRef(element);
}, [setDraggableRef, setDroppableRef]);
const setNodeRef = useCallback(
(element: HTMLDivElement | null) => {
setDraggableRef(element);
setDroppableRef(element);
},
[setDraggableRef, setDroppableRef],
);
return (
<>
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, activeDragIds, justMoved, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)}
{renderItem(
item,
index,
itemRefs,
boxSelectedPageIds,
clearBoxSelection,
activeDragIds,
justMoved,
{ ref: setNodeRef, ...attributes, ...listeners },
zoomLevel,
)}
</>
);
};
@@ -303,18 +348,17 @@ const DragDropGrid = <T extends DragDropItem>({
const containerRef = useRef<HTMLDivElement>(null);
const getScrollElement = useCallback(() => {
return containerRef.current?.closest('[data-scrolling-container]') as HTMLElement | null;
return containerRef.current?.closest("[data-scrolling-container]") as HTMLElement | null;
}, []);
// Create stable signature for items to ensure useMemo detects changes
const itemsSignature = useMemo(() => items.map(item => item.id).join(','), [items]);
const selectedFileIdsSignature = useMemo(() => selectedFileIds?.join(',') || '', [selectedFileIds]);
const itemsSignature = useMemo(() => items.map((item) => item.id).join(","), [items]);
const selectedFileIdsSignature = useMemo(() => selectedFileIds?.join(",") || "", [selectedFileIds]);
const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => {
const filtered: T[] = [];
const indexMap: number[] = [];
const selectedIds =
selectedFileIds && selectedFileIds.length > 0 ? new Set(selectedFileIds) : null;
const selectedIds = selectedFileIds && selectedFileIds.length > 0 ? new Set(selectedFileIds) : null;
items.forEach((item, index) => {
const isPlaceholder = Boolean(item.isPlaceholder);
@@ -322,8 +366,7 @@ const DragDropGrid = <T extends DragDropItem>({
return;
}
const belongsToVisibleFile =
!selectedIds || !item.originalFileId || selectedIds.has(item.originalFileId);
const belongsToVisibleFile = !selectedIds || !item.originalFileId || selectedIds.has(item.originalFileId);
if (!belongsToVisibleFile) {
return;
@@ -337,7 +380,7 @@ const DragDropGrid = <T extends DragDropItem>({
}, [items, selectedFileIds, itemsSignature, selectedFileIdsSignature]);
useEffect(() => {
const visibleIdSet = new Set(visibleItems.map(item => item.id));
const visibleIdSet = new Set(visibleItems.map((item) => item.id));
itemRefs.current.forEach((_, pageId) => {
if (!visibleIdSet.has(pageId)) {
itemRefs.current.delete(pageId);
@@ -366,7 +409,7 @@ const DragDropGrid = <T extends DragDropItem>({
activationConstraint: {
distance: 10,
},
})
}),
);
// Throttled pointer move handler for drop indicator
@@ -395,9 +438,9 @@ const DragDropGrid = <T extends DragDropItem>({
}
};
window.addEventListener('pointermove', handlePointerMove, { passive: true });
window.addEventListener("pointermove", handlePointerMove, { passive: true });
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener("pointermove", handlePointerMove);
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
@@ -439,7 +482,7 @@ const DragDropGrid = <T extends DragDropItem>({
updateLayout();
// Listen for window resize
window.addEventListener('resize', updateLayout);
window.addEventListener("resize", updateLayout);
// Use ResizeObserver for container size changes
const resizeObserver = new ResizeObserver(updateLayout);
@@ -448,7 +491,7 @@ const DragDropGrid = <T extends DragDropItem>({
}
return () => {
window.removeEventListener('resize', updateLayout);
window.removeEventListener("resize", updateLayout);
resizeObserver.disconnect();
};
}, [calculateItemsPerRow, zoomLevel]);
@@ -481,7 +524,7 @@ const DragDropGrid = <T extends DragDropItem>({
// Re-measure virtualizer when zoom or items per row changes
// Also remeasure when items change (not just length) to handle item additions/removals
const visibleItemsSignature = useMemo(() => visibleItems.map(item => item.id).join(','), [visibleItems]);
const visibleItemsSignature = useMemo(() => visibleItems.map((item) => item.id).join(","), [visibleItems]);
useEffect(() => {
rowVirtualizer.measure();
}, [zoomLevel, itemsPerRow, visibleItems.length, visibleItemsSignature, rowVirtualizer]);
@@ -497,76 +540,77 @@ const DragDropGrid = <T extends DragDropItem>({
}, []);
// Box selection handlers
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return; // Only respond to primary button
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) return; // Only respond to primary button
const container = containerRef.current;
if (!container) return;
const container = containerRef.current;
if (!container) return;
const clickTarget = e.target as Node;
let clickedPageId: string | null = null;
const clickTarget = e.target as Node;
let clickedPageId: string | null = null;
itemRefs.current.forEach((element, pageId) => {
if (element.contains(clickTarget)) {
clickedPageId = pageId;
itemRefs.current.forEach((element, pageId) => {
if (element.contains(clickTarget)) {
clickedPageId = pageId;
}
});
if (clickedPageId) {
// Clicking directly on a page shouldn't initiate box selection
// but clear previous box selection if clicking outside current group
if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) {
setBoxSelectedPageIds([]);
}
return;
}
});
if (clickedPageId) {
// Clicking directly on a page shouldn't initiate box selection
// but clear previous box selection if clicking outside current group
if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) {
setBoxSelectedPageIds([]);
}
return;
}
e.preventDefault();
e.preventDefault();
const rect = container.getBoundingClientRect();
setIsBoxSelecting(true);
setBoxSelectStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setBoxSelectedPageIds([]);
},
[boxSelectedPageIds],
);
const rect = container.getBoundingClientRect();
setIsBoxSelecting(true);
setBoxSelectStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setBoxSelectedPageIds([]);
}, [boxSelectedPageIds]);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isBoxSelecting || !boxSelectStart) return;
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isBoxSelecting || !boxSelectStart) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
// Calculate which pages intersect with selection box
const boxLeft = Math.min(boxSelectStart.x, e.clientX - rect.left);
const boxRight = Math.max(boxSelectStart.x, e.clientX - rect.left);
const boxTop = Math.min(boxSelectStart.y, e.clientY - rect.top);
const boxBottom = Math.max(boxSelectStart.y, e.clientY - rect.top);
// Calculate which pages intersect with selection box
const boxLeft = Math.min(boxSelectStart.x, e.clientX - rect.left);
const boxRight = Math.max(boxSelectStart.x, e.clientX - rect.left);
const boxTop = Math.min(boxSelectStart.y, e.clientY - rect.top);
const boxBottom = Math.max(boxSelectStart.y, e.clientY - rect.top);
const selectedIds: string[] = [];
itemRefs.current.forEach((pageEl, pageId) => {
const pageRect = pageEl.getBoundingClientRect();
const pageLeft = pageRect.left - rect.left;
const pageRight = pageRect.right - rect.left;
const pageTop = pageRect.top - rect.top;
const pageBottom = pageRect.bottom - rect.top;
const selectedIds: string[] = [];
itemRefs.current.forEach((pageEl, pageId) => {
const pageRect = pageEl.getBoundingClientRect();
const pageLeft = pageRect.left - rect.left;
const pageRight = pageRect.right - rect.left;
const pageTop = pageRect.top - rect.top;
const pageBottom = pageRect.bottom - rect.top;
// Check if page intersects with selection box
const intersects = !(pageRight < boxLeft || pageLeft > boxRight || pageBottom < boxTop || pageTop > boxBottom);
// Check if page intersects with selection box
const intersects = !(
pageRight < boxLeft ||
pageLeft > boxRight ||
pageBottom < boxTop ||
pageTop > boxBottom
);
if (intersects) {
selectedIds.push(pageId);
}
});
if (intersects) {
selectedIds.push(pageId);
}
});
setBoxSelectedPageIds(selectedIds);
}, [isBoxSelecting, boxSelectStart]);
setBoxSelectedPageIds(selectedIds);
},
[isBoxSelecting, boxSelectStart],
);
const handleMouseUp = useCallback(() => {
if (isBoxSelecting) {
@@ -601,7 +645,6 @@ const DragDropGrid = <T extends DragDropItem>({
setDragPreview(null);
}, []);
// Handle drag cancel
const handleDragCancel = useCallback(() => {
setActiveId(null);
@@ -611,64 +654,76 @@ const DragDropGrid = <T extends DragDropItem>({
}, []);
// Handle drag end
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
const finalDropSide = dropSide;
setActiveId(null);
setDragPreview(null);
setHoveredItemId(null);
setDropSide(null);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
const finalDropSide = dropSide;
setActiveId(null);
setDragPreview(null);
setHoveredItemId(null);
setDropSide(null);
if (!over || active.id === over.id) {
return;
}
if (!over || active.id === over.id) {
return;
}
const activeData = active.data.current;
if (!activeData) {
return;
}
const activeData = active.data.current;
if (!activeData) {
return;
}
const sourcePageNumber = activeData.pageNumber;
const sourcePageNumber = activeData.pageNumber;
const overData = over?.data.current;
let targetIndex = resolveTargetIndex(
const overData = over?.data.current;
let targetIndex = resolveTargetIndex(
hoveredItemId,
finalDropSide,
visibleItems,
filteredToOriginalIndex,
items.length,
overData ? overData.index : null,
);
if (targetIndex === null) {
return;
}
if (targetIndex < 0) targetIndex = 0;
if (targetIndex > items.length) targetIndex = items.length;
// Check if this page is box-selected
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
// Call reorder with page number and target index
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
// Highlight moved pages briefly
const movedIds = pagesToDrag ?? [active.id as string];
setJustMovedIds(movedIds);
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current);
}
highlightTimeoutRef.current = window.setTimeout(() => {
setJustMovedIds([]);
highlightTimeoutRef.current = null;
}, 1200);
// Clear box selection after drag
if (pagesToDrag) {
clearBoxSelection();
}
},
[
boxSelectedPageIds,
dropSide,
hoveredItemId,
finalDropSide,
visibleItems,
filteredToOriginalIndex,
items.length,
overData ? overData.index : null
);
if (targetIndex === null) {
return;
}
if (targetIndex < 0) targetIndex = 0;
if (targetIndex > items.length) targetIndex = items.length;
// Check if this page is box-selected
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
// Call reorder with page number and target index
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
// Highlight moved pages briefly
const movedIds = pagesToDrag ?? [active.id as string];
setJustMovedIds(movedIds);
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current);
}
highlightTimeoutRef.current = window.setTimeout(() => {
setJustMovedIds([]);
highlightTimeoutRef.current = null;
}, 1200);
// Clear box selection after drag
if (pagesToDrag) {
clearBoxSelection();
}
}, [boxSelectedPageIds, dropSide, hoveredItemId, visibleItems, filteredToOriginalIndex, items, onReorderPages, clearBoxSelection]);
items,
onReorderPages,
clearBoxSelection,
],
);
// Calculate optimal width for centering
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
@@ -677,13 +732,16 @@ const DragDropGrid = <T extends DragDropItem>({
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
// Calculate selection box dimensions
const selectionBoxStyle = isBoxSelecting && boxSelectStart && boxSelectEnd ? {
left: Math.min(boxSelectStart.x, boxSelectEnd.x),
top: Math.min(boxSelectStart.y, boxSelectEnd.y),
width: Math.abs(boxSelectEnd.x - boxSelectStart.x),
height: Math.abs(boxSelectEnd.y - boxSelectStart.y),
zIndex: Z_INDEX_SELECTION_BOX,
} : null;
const selectionBoxStyle =
isBoxSelecting && boxSelectStart && boxSelectEnd
? {
left: Math.min(boxSelectStart.x, boxSelectEnd.x),
top: Math.min(boxSelectStart.y, boxSelectEnd.y),
width: Math.abs(boxSelectEnd.x - boxSelectStart.x),
height: Math.abs(boxSelectEnd.y - boxSelectStart.y),
zIndex: Z_INDEX_SELECTION_BOX,
}
: null;
// Calculate drop indicator position
const dropIndicatorStyle = useMemo(() => {
@@ -698,9 +756,10 @@ const DragDropGrid = <T extends DragDropItem>({
const top = itemRect.top - containerRect.top;
const height = itemRect.height;
const left = dropSide === 'left'
? itemRect.left - containerRect.left - itemGap / 2
: itemRect.right - containerRect.left + itemGap / 2;
const left =
dropSide === "left"
? itemRect.left - containerRect.left - itemGap / 2
: itemRect.right - containerRect.left + itemGap / 2;
return {
left: `${left}px`,
@@ -718,23 +777,26 @@ const DragDropGrid = <T extends DragDropItem>({
return [activeId];
}, [activeId, boxSelectedPageIds]);
const handleWheelWhileDragging = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (!activeId) {
return;
}
const handleWheelWhileDragging = useCallback(
(event: React.WheelEvent<HTMLDivElement>) => {
if (!activeId) {
return;
}
const scrollElement = getScrollElement();
if (!scrollElement) {
return;
}
const scrollElement = getScrollElement();
if (!scrollElement) {
return;
}
scrollElement.scrollBy({
top: event.deltaY,
left: event.deltaX,
});
scrollElement.scrollBy({
top: event.deltaY,
left: event.deltaX,
});
event.preventDefault();
}, [activeId, getScrollElement]);
event.preventDefault();
},
[activeId, getScrollElement],
);
return (
<DndContext
@@ -752,26 +814,16 @@ const DragDropGrid = <T extends DragDropItem>({
onMouseUp={handleMouseUp}
onWheel={handleWheelWhileDragging}
>
{selectionBoxStyle && (
<div
className={styles.selectionBox}
style={selectionBoxStyle}
/>
)}
{selectionBoxStyle && <div className={styles.selectionBox} style={selectionBoxStyle} />}
{dropIndicatorStyle && (
<div
className={styles.dropIndicator}
style={dropIndicatorStyle}
/>
)}
{dropIndicatorStyle && <div className={styles.dropIndicator} style={dropIndicatorStyle} />}
<div
className={styles.virtualRows}
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
maxWidth: `${gridWidth}px`,
margin: '0 auto',
margin: "0 auto",
}}
>
{virtualRows.map((virtualRow) => {
@@ -826,10 +878,7 @@ const DragDropGrid = <T extends DragDropItem>({
{activeId && (
<div className={styles.dragOverlay}>
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && (
<div
className={styles.dragOverlayBadge}
style={{ zIndex: Z_INDEX_DRAG_BADGE }}
>
<div className={styles.dragOverlayBadge} style={{ zIndex: Z_INDEX_DRAG_BADGE }}>
{boxSelectedPageIds.length}
</div>
)}
@@ -840,9 +889,9 @@ const DragDropGrid = <T extends DragDropItem>({
style={{
width: `calc(20rem * ${zoomLevel})`,
height: `calc(20rem * ${zoomLevel})`,
objectFit: 'contain',
objectFit: "contain",
transform: `rotate(${dragPreview.rotation}deg)`,
pointerEvents: 'none',
pointerEvents: "none",
opacity: 0.5,
}}
/>
@@ -852,11 +901,11 @@ const DragDropGrid = <T extends DragDropItem>({
style={{
width: `calc(20rem * ${zoomLevel})`,
height: `calc(20rem * ${zoomLevel})`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2rem',
color: 'var(--mantine-color-dimmed)',
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "2rem",
color: "var(--mantine-color-dimmed)",
}}
>
<LocalIcon icon="description" width="3rem" height="3rem" />
@@ -1,20 +1,20 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { ActionIcon, CheckboxIndicator } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import React, { useState, useCallback, useRef, useMemo, useEffect } from "react";
import { ActionIcon, CheckboxIndicator } from "@mantine/core";
import { useTranslation } from "react-i18next";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import PushPinIcon from "@mui/icons-material/PushPin";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import styles from '@app/components/pageEditor/PageEditor.module.css';
import { useFileContext } from '@app/contexts/FileContext';
import { FileId } from '@app/types/file';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { downloadFile } from '@app/services/downloadService';
import styles from "@app/components/pageEditor/PageEditor.module.css";
import { useFileContext } from "@app/contexts/FileContext";
import { FileId } from "@app/types/file";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { downloadFile } from "@app/services/downloadService";
interface FileItem {
id: FileId;
@@ -66,13 +66,13 @@ const FileThumbnail = ({
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
return activeFiles.find(f => f.fileId === file.id);
return activeFiles.find((f) => f.fileId === file.id);
}, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
const downloadSelectedFile = useCallback(() => {
// Prefer parent-provided handler if available
if (typeof onDownloadFile === 'function') {
if (typeof onDownloadFile === "function") {
onDownloadFile(file.id);
return;
}
@@ -80,7 +80,7 @@ const FileThumbnail = ({
// Fallback: attempt to download using the File object if provided
const maybeFile = (file as unknown as { file?: File }).file;
if (maybeFile instanceof File) {
void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || 'download' });
void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || "download" });
return;
}
@@ -93,52 +93,55 @@ const FileThumbnail = ({
const isSelected = selectedFiles.includes(file.id);
// ---- Drag & drop wiring ----
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
if (!element) return;
const fileElementRef = useCallback(
(element: HTMLDivElement | null) => {
if (!element) return;
dragElementRef.current = element;
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: 'file',
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id] // Always drag only this file, ignore selection state
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
}
});
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: "file",
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id], // Always drag only this file, ignore selection state
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'file',
fileId: file.id
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === 'file' && sourceData.fileId !== file.id;
},
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === 'file' && onReorderFiles) {
const sourceFileId = sourceData.fileId as FileId;
const selectedFileIds = sourceData.selectedFiles as FileId[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
}
}
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: "file",
fileId: file.id,
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === "file" && sourceData.fileId !== file.id;
},
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === "file" && onReorderFiles) {
const sourceFileId = sourceData.fileId as FileId;
const selectedFileIds = sourceData.selectedFiles as FileId[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
}
},
});
return () => {
dragCleanup();
dropCleanup();
};
}, [file.id, file.name, selectedFiles, onReorderFiles]);
return () => {
dragCleanup();
dropCleanup();
};
},
[file.id, file.name, selectedFiles, onReorderFiles],
);
// Update dropdown width on resize
useEffect(() => {
@@ -146,8 +149,8 @@ const FileThumbnail = ({
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
// Close the actions dropdown when hovering outside this file card (and its dropdown)
@@ -173,11 +176,11 @@ const FileThumbnail = ({
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('touchstart', handleTouchStart, { passive: true });
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("touchstart", handleTouchStart, { passive: true });
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("touchstart", handleTouchStart);
};
}, [showActions]);
@@ -187,7 +190,6 @@ const FileThumbnail = ({
onToggleFile(file.id);
};
return (
<div
ref={fileElementRef}
@@ -198,7 +200,7 @@ const FileThumbnail = ({
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
style={{
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)',
filter: isSupported ? "none" : "grayscale(50%)",
}}
tabIndex={0}
role="listitem"
@@ -206,11 +208,7 @@ const FileThumbnail = ({
onClick={handleCardClick}
>
{/* Header bar */}
<div
className={`${styles.header} ${
isSelected ? styles.headerSelected : styles.headerResting
}`}
>
<div className={`${styles.header} ${isSelected ? styles.headerSelected : styles.headerResting}`}>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{isSupported ? (
@@ -221,9 +219,7 @@ const FileThumbnail = ({
/>
) : (
<div className={styles.unsupportedPill}>
<span>
{t('unsupported', 'Unsupported')}
</span>
<span>{t("unsupported", "Unsupported")}</span>
</div>
)}
</div>
@@ -235,7 +231,7 @@ const FileThumbnail = ({
{/* Kebab menu */}
<ActionIcon
aria-label={t('moreOptions', 'More options')}
aria-label={t("moreOptions", "More options")}
variant="subtle"
className={styles.kebab}
onClick={(e) => {
@@ -249,11 +245,7 @@ const FileThumbnail = ({
{/* Actions overlay */}
{showActions && (
<div
className={styles.actionsOverlay}
style={{ width: actionsWidth }}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.actionsOverlay} style={{ width: actionsWidth }} onClick={(e) => e.stopPropagation()}>
<button
className={styles.actionRow}
onClick={() => {
@@ -270,12 +262,15 @@ const FileThumbnail = ({
}}
>
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
<span>{isPinned ? t("unpin", "Unpin") : t("pin", "Pin")}</span>
</button>
<button
className={styles.actionRow}
onClick={() => { downloadSelectedFile(); setShowActions(false); }}
onClick={() => {
downloadSelectedFile();
setShowActions(false);
}}
>
<DownloadOutlinedIcon fontSize="small" />
<span>{terminology.download}</span>
@@ -292,7 +287,7 @@ const FileThumbnail = ({
}}
>
<DeleteOutlineIcon fontSize="small" />
<span>{t('delete', 'Delete')}</span>
<span>{t("delete", "Delete")}</span>
</button>
</div>
)}
@@ -302,17 +297,17 @@ const FileThumbnail = ({
{/* Stacked file effect - multiple shadows to simulate pages */}
<div
style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
width: "100%",
height: "100%",
backgroundColor: "var(--mantine-color-gray-1)",
borderRadius: 6,
border: '1px solid var(--mantine-color-gray-3)',
border: "1px solid var(--mantine-color-gray-3)",
padding: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
boxShadow: "2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)",
}}
>
{file.thumbnail && (
@@ -324,21 +319,21 @@ const FileThumbnail = ({
onError={(e) => {
// Hide broken image if blob URL was revoked
const img = e.target as HTMLImageElement;
img.style.display = 'none';
img.style.display = "none";
}}
style={{
maxWidth: '80%',
maxHeight: '80%',
objectFit: 'contain',
borderRadius: 0,
background: '#ffffff',
border: '1px solid var(--border-default)',
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
alignSelf: 'start'
}}
/>
style={{
maxWidth: "80%",
maxHeight: "80%",
objectFit: "contain",
borderRadius: 0,
background: "#ffffff",
border: "1px solid var(--border-default)",
display: "block",
marginLeft: "auto",
marginRight: "auto",
alignSelf: "start",
}}
/>
</PrivateContent>
)}
</div>
@@ -52,7 +52,8 @@
/* Animations */
@keyframes pulse {
0%, 100% {
0%,
100% {
opacity: 1;
}
50% {
@@ -61,18 +62,18 @@
}
/* Action styles */
.actionRow:hover {
background: var(--hover-bg);
.actionRow:hover {
background: var(--hover-bg);
}
.actionDanger {
color: var(--text-brand-accent);
.actionDanger {
color: var(--text-brand-accent);
}
.actionsDivider {
height: 1px;
background: var(--border-default);
margin: 4px 0;
.actionsDivider {
height: 1px;
background: var(--border-default);
margin: 4px 0;
}
.pinIndicator {
@@ -85,7 +86,7 @@
.unsupportedPill {
margin-left: 1.75rem;
background: #6B7280;
background: #6b7280;
color: white;
padding: 4px 8px;
border-radius: 12px;
@@ -5,15 +5,15 @@ import { useNavigationGuard, useNavigationState } from "@app/contexts/Navigation
import { usePageEditor } from "@app/contexts/PageEditorContext";
import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor";
// Thumbnail generation is now handled by individual PageThumbnail components
import '@app/components/pageEditor/PageEditor.module.css';
import PageThumbnail from '@app/components/pageEditor/PageThumbnail';
import DragDropGrid from '@app/components/pageEditor/DragDropGrid';
import SkeletonLoader from '@app/components/shared/SkeletonLoader';
import "@app/components/pageEditor/PageEditor.module.css";
import PageThumbnail from "@app/components/pageEditor/PageThumbnail";
import DragDropGrid from "@app/components/pageEditor/DragDropGrid";
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
import { FileId } from "@app/types/file";
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
import { useInitialPageDocument } from '@app/components/pageEditor/hooks/useInitialPageDocument';
import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument';
import { usePageEditorState } from '@app/components/pageEditor/hooks/usePageEditorState';
import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
import { useInitialPageDocument } from "@app/components/pageEditor/hooks/useInitialPageDocument";
import { usePageDocument } from "@app/components/pageEditor/hooks/usePageDocument";
import { usePageEditorState } from "@app/components/pageEditor/hooks/usePageEditorState";
import { usePageEditorRightRailButtons } from "@app/components/pageEditor/pageEditorRightRailButtons";
import { useFileColorMap } from "@app/components/pageEditor/hooks/useFileColorMap";
import { useWheelZoom } from "@app/hooks/useWheelZoom";
@@ -23,22 +23,20 @@ import { usePageSelectionManager } from "@app/components/pageEditor/hooks/usePag
import { usePageEditorCommands } from "@app/components/pageEditor/hooks/useEditorCommands";
import { usePageEditorExport } from "@app/components/pageEditor/hooks/usePageEditorExport";
import { useThumbnailGeneration } from "@app/hooks/useThumbnailGeneration";
import { convertSplitPageIdsToIndexes } from '@app/components/pageEditor/utils/splitPositions';
import { convertSplitPageIdsToIndexes } from "@app/components/pageEditor/utils/splitPositions";
export interface PageEditorProps {
onFunctionsReady?: (functions: PageEditorFunctions) => void;
}
const PageEditor = ({
onFunctionsReady,
}: PageEditorProps) => {
const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
// Use split contexts to prevent re-renders
const { state, selectors } = useFileState();
const { actions } = useFileActions();
// Navigation guard for unsaved changes
const { setHasUnsavedChanges, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } = useNavigationGuard();
const { setHasUnsavedChanges, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } =
useNavigationGuard();
const navigationState = useNavigationState();
// Get PageEditor coordination functions
@@ -56,8 +54,8 @@ const PageEditor = ({
const thumbnailRequestsRef = useRef<Set<string>>(new Set());
const { requestThumbnail, getThumbnailFromCache } = useThumbnailGeneration();
const handleVisibleItemsChange = useCallback((items: PDFPage[]) => {
setVisiblePageIds(prev => {
const ids = items.map(item => item.id);
setVisiblePageIds((prev) => {
const ids = items.map((item) => item.id);
if (prev.length === ids.length && prev.every((id, index) => id === ids[index])) {
return prev;
}
@@ -70,7 +68,7 @@ const PageEditor = ({
const containerRef = useRef<HTMLDivElement>(null);
const [isContainerHovered, setIsContainerHovered] = useState(false);
const rootFontSize = useMemo(() => {
if (typeof window === 'undefined') {
if (typeof window === "undefined") {
return 16;
}
const computed = getComputedStyle(document.documentElement).fontSize;
@@ -83,19 +81,19 @@ const PageEditor = ({
// Zoom actions
const zoomIn = useCallback(() => {
setZoomLevel(prev => Math.min(prev + 0.1, 3.0));
setZoomLevel((prev) => Math.min(prev + 0.1, 3.0));
}, []);
const zoomOut = useCallback(() => {
setZoomLevel(prev => Math.max(prev - 0.1, 0.5));
setZoomLevel((prev) => Math.max(prev - 0.1, 0.5));
}, []);
// Derive page editor files from PageEditorContext's fileOrder (page editor workspace order)
// Filter to only show PDF files (PageEditor only supports PDFs)
// Use stable string keys to prevent infinite loops
// Cache file objects to prevent infinite re-renders from new object references
const fileOrderKey = fileOrder.join(',');
const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(',');
const fileOrderKey = fileOrder.join(",");
const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(",");
const filesSignature = selectors.getFilesSignature();
const fileObjectsRef = useRef(new Map<FileId, any>());
@@ -105,28 +103,30 @@ const PageEditor = ({
const cache = fileObjectsRef.current;
const newFiles: any[] = [];
fileOrder.forEach(fileId => {
fileOrder.forEach((fileId) => {
const stub = selectors.getStirlingFileStub(fileId);
const isSelected = state.ui.selectedFileIds.includes(fileId);
const isPdf = stub?.name?.toLowerCase().endsWith('.pdf') ?? false;
const isPdf = stub?.name?.toLowerCase().endsWith(".pdf") ?? false;
if (!isPdf) return; // Skip non-PDFs
const cached = cache.get(fileId);
// Check if data actually changed (compare by fileId, not position)
if (cached &&
cached.fileId === fileId &&
cached.name === (stub?.name || '') &&
cached.versionNumber === stub?.versionNumber &&
cached.isSelected === isSelected) {
if (
cached &&
cached.fileId === fileId &&
cached.name === (stub?.name || "") &&
cached.versionNumber === stub?.versionNumber &&
cached.isSelected === isSelected
) {
// Reuse existing object reference
newFiles.push(cached);
} else {
// Create new object only if data changed
const newFile = {
fileId,
name: stub?.name || '',
name: stub?.name || "",
versionNumber: stub?.versionNumber,
isSelected,
};
@@ -136,7 +136,7 @@ const PageEditor = ({
});
// Clean up removed files from cache
const activeIds = new Set(newFiles.map(f => f.fileId));
const activeIds = new Set(newFiles.map((f) => f.fileId));
for (const cachedId of cache.keys()) {
if (!activeIds.has(cachedId)) {
cache.delete(cachedId);
@@ -148,12 +148,12 @@ const PageEditor = ({
// Get ALL file IDs in order (not filtered by selection)
const orderedFileIds = useMemo(() => {
return pageEditorFiles.map(f => f.fileId);
return pageEditorFiles.map((f) => f.fileId);
}, [pageEditorFiles]);
// Get selected file IDs for filtering
const selectedFileIds = useMemo(() => {
return pageEditorFiles.filter(f => f.isSelected).map(f => f.fileId);
return pageEditorFiles.filter((f) => f.isSelected).map((f) => f.fileId);
}, [pageEditorFiles]);
const activeFilesSignature = selectors.getFilesSignature();
@@ -177,88 +177,83 @@ const PageEditor = ({
displayDocumentRef.current = displayDocument;
}, [displayDocument]);
const queueThumbnailRequestsForPages = useCallback((pageIds: string[]) => {
const doc = displayDocumentRef.current;
if (!doc || pageIds.length === 0) return;
const queueThumbnailRequestsForPages = useCallback(
(pageIds: string[]) => {
const doc = displayDocumentRef.current;
if (!doc || pageIds.length === 0) return;
const loadedCount = doc.pages.filter(p => p.thumbnail).length;
const pending = thumbnailRequestsRef.current.size;
const MAX_CONCURRENT_THUMBNAILS = loadedCount < 8 ? 1
: doc.totalPages < 20 ? 3
: doc.totalPages < 50 ? 5
: 8;
const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending);
if (available === 0) return;
const loadedCount = doc.pages.filter((p) => p.thumbnail).length;
const pending = thumbnailRequestsRef.current.size;
const MAX_CONCURRENT_THUMBNAILS = loadedCount < 8 ? 1 : doc.totalPages < 20 ? 3 : doc.totalPages < 50 ? 5 : 8;
const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending);
if (available === 0) return;
const toLoad: string[] = [];
for (const pageId of pageIds) {
if (toLoad.length >= available) break;
if (thumbnailRequestsRef.current.has(pageId)) continue;
const page = doc.pages.find(p => p.id === pageId);
if (!page || page.thumbnail) continue;
toLoad.push(pageId);
}
const toLoad: string[] = [];
for (const pageId of pageIds) {
if (toLoad.length >= available) break;
if (thumbnailRequestsRef.current.has(pageId)) continue;
const page = doc.pages.find((p) => p.id === pageId);
if (!page || page.thumbnail) continue;
toLoad.push(pageId);
}
if (toLoad.length === 0) return;
if (toLoad.length === 0) return;
toLoad.forEach(pageId => {
const page = doc.pages.find(p => p.id === pageId);
if (!page) return;
toLoad.forEach((pageId) => {
const page = doc.pages.find((p) => p.id === pageId);
if (!page) return;
const cached = getThumbnailFromCache(pageId);
if (cached) {
thumbnailRequestsRef.current.add(pageId);
Promise.resolve(cached)
.then(cache => {
setEditedDocument(prev => {
if (!prev) return prev;
const pageIndex = prev.pages.findIndex(p => p.id === pageId);
if (pageIndex === -1) return prev;
const cached = getThumbnailFromCache(pageId);
if (cached) {
thumbnailRequestsRef.current.add(pageId);
Promise.resolve(cached)
.then((cache) => {
setEditedDocument((prev) => {
if (!prev) return prev;
const pageIndex = prev.pages.findIndex((p) => p.id === pageId);
if (pageIndex === -1) return prev;
const updated = [...prev.pages];
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail: cache };
return { ...prev, pages: updated };
const updated = [...prev.pages];
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail: cache };
return { ...prev, pages: updated };
});
})
.finally(() => {
thumbnailRequestsRef.current.delete(pageId);
});
return;
}
const fileId = page.originalFileId;
if (!fileId) return;
const file = selectors.getFile(fileId);
if (!file) return;
thumbnailRequestsRef.current.add(pageId);
requestThumbnail(pageId, file, page.originalPageNumber || page.pageNumber)
.then((thumbnail) => {
if (thumbnail) {
setEditedDocument((prev) => {
if (!prev) return prev;
const pageIndex = prev.pages.findIndex((p) => p.id === pageId);
if (pageIndex === -1) return prev;
const updated = [...prev.pages];
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail };
return { ...prev, pages: updated };
});
}
})
.catch((error) => {
console.error("[Thumbnail Loading] Error:", error);
})
.finally(() => {
thumbnailRequestsRef.current.delete(pageId);
});
return;
}
const fileId = page.originalFileId;
if (!fileId) return;
const file = selectors.getFile(fileId);
if (!file) return;
thumbnailRequestsRef.current.add(pageId);
requestThumbnail(pageId, file, page.originalPageNumber || page.pageNumber)
.then(thumbnail => {
if (thumbnail) {
setEditedDocument(prev => {
if (!prev) return prev;
const pageIndex = prev.pages.findIndex(p => p.id === pageId);
if (pageIndex === -1) return prev;
const updated = [...prev.pages];
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail };
return { ...prev, pages: updated };
});
}
})
.catch((error) => {
console.error('[Thumbnail Loading] Error:', error);
})
.finally(() => {
thumbnailRequestsRef.current.delete(pageId);
});
});
}, [
getThumbnailFromCache,
requestThumbnail,
selectors,
setEditedDocument
]);
});
},
[getThumbnailFromCache, requestThumbnail, selectors, setEditedDocument],
);
useEffect(() => {
if (!displayDocument) {
@@ -282,9 +277,7 @@ const PageEditor = ({
}
const INITIAL_VISIBLE_PAGE_COUNT = 8;
const initialIds = displayDocument.pages
.slice(0, INITIAL_VISIBLE_PAGE_COUNT)
.map(page => page.id);
const initialIds = displayDocument.pages.slice(0, INITIAL_VISIBLE_PAGE_COUNT).map((page) => page.id);
queueThumbnailRequestsForPages(initialIds);
lastInitialDocumentSignatureRef.current = signature;
@@ -296,13 +289,13 @@ const PageEditor = ({
useEffect(() => {
return () => {
if (navigationState.workbench !== 'pageEditor') {
if (navigationState.workbench !== "pageEditor") {
return;
}
const doc = displayDocumentRef.current;
if (doc && doc.pages.length > 0) {
const signature = doc.pages.map(page => page.id).join(',');
const signature = doc.pages.map((page) => page.id).join(",");
savePersistedDocument(doc, signature);
}
};
@@ -310,9 +303,20 @@ const PageEditor = ({
// UI state management
const {
selectionMode, selectedPageIds, movingPage, isAnimating, splitPositions, exportLoading,
setSelectionMode, setSelectedPageIds, setMovingPage, setSplitPositions, setExportLoading,
togglePage, toggleSelectAll, animateReorder
selectionMode,
selectedPageIds,
movingPage,
isAnimating,
splitPositions,
exportLoading,
setSelectionMode,
setSelectedPageIds,
setMovingPage,
setSplitPositions,
setExportLoading,
togglePage,
toggleSelectAll,
animateReorder,
} = usePageEditorState();
const {
@@ -336,14 +340,9 @@ const PageEditor = ({
// Grid container ref for positioning split indicators
const gridContainerRef = useRef<HTMLDivElement>(null);
const {
canUndo,
canRedo,
executeCommandWithTracking,
handleUndo,
handleRedo,
clearUndoHistory,
} = useUndoManagerState({ setHasUnsavedChanges });
const { canUndo, canRedo, executeCommandWithTracking, handleUndo, handleRedo, clearUndoHistory } = useUndoManagerState({
setHasUnsavedChanges,
});
const {
createRotateCommand,
@@ -428,17 +427,20 @@ const PageEditor = ({
});
// Export preview function - defined after export functions to avoid circular dependency
const handleExportPreview = useCallback((selectedOnly: boolean = false) => {
if (!displayDocument) return;
const handleExportPreview = useCallback(
(selectedOnly: boolean = false) => {
if (!displayDocument) return;
// For now, trigger the actual export directly
// In the original, this would show a preview modal first
if (selectedOnly) {
onExportSelected();
} else {
onExportAll();
}
}, [displayDocument, onExportSelected, onExportAll]);
// For now, trigger the actual export directly
// In the original, this would show a preview modal first
if (selectedOnly) {
onExportSelected();
} else {
onExportAll();
}
},
[displayDocument, onExportSelected, onExportAll],
);
// Expose functions to parent component
useEffect(() => {
@@ -471,9 +473,30 @@ const PageEditor = ({
});
}
}, [
onFunctionsReady, handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, handleSplitAll,
handlePageBreak, handlePageBreakAll, handleSelectAll, handleDeselectAll, handleSetSelectedPages, handleExportPreview, onExportSelected, onExportAll, applyChanges, exportLoading,
selectionMode, selectedPageIds, splitPositions, displayDocument?.pages.length, closePdf
onFunctionsReady,
handleUndo,
handleRedo,
canUndo,
canRedo,
handleRotate,
handleDelete,
handleSplit,
handleSplitAll,
handlePageBreak,
handlePageBreakAll,
handleSelectAll,
handleDeselectAll,
handleSetSelectedPages,
handleExportPreview,
onExportSelected,
onExportAll,
applyChanges,
exportLoading,
selectionMode,
selectedPageIds,
splitPositions,
displayDocument?.pages.length,
closePdf,
]);
useWheelZoom({
@@ -490,15 +513,15 @@ const PageEditor = ({
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
if (event.ctrlKey || event.metaKey) {
if (event.key === '=' || event.key === '+') {
if (event.key === "=" || event.key === "+") {
// Ctrl+= or Ctrl++ for zoom in
event.preventDefault();
zoomIn();
} else if (event.key === '-' || event.key === '_') {
} else if (event.key === "-" || event.key === "_") {
// Ctrl+- for zoom out
event.preventDefault();
zoomOut();
} else if (event.key === '0') {
} else if (event.key === "0") {
// Ctrl+0 for reset zoom
event.preventDefault();
setZoomLevel(1.0);
@@ -506,9 +529,9 @@ const PageEditor = ({
}
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isContainerHovered, zoomIn, zoomOut]);
@@ -520,75 +543,78 @@ const PageEditor = ({
// Memoize renderItem to prevent DragDropGrid's React.memo from blocking updates
// when selectedPageIds changes
const renderItemCallback = useCallback((
page: PDFPage,
index: number,
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
boxSelectedIds: string[],
clearBoxSelection: () => void,
activeDragIds: string[],
justMoved: boolean,
dragHandleProps?: any,
zoomLevelParam?: number
) => {
gridItemRefsRef.current = refs;
const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0;
const isBoxSelected = boxSelectedIds.includes(page.id);
return (
<PageThumbnail
key={page.id}
page={page}
index={index}
totalPages={displayDocument?.pages.length || 0}
fileColorIndex={fileColorIndex}
selectedPageIds={selectedPageIds}
selectionMode={selectionMode}
movingPage={movingPage}
isAnimating={isAnimating}
isBoxSelected={isBoxSelected}
clearBoxSelection={clearBoxSelection}
activeDragIds={activeDragIds}
justMoved={justMoved}
pageRefs={refs}
dragHandleProps={dragHandleProps}
onReorderPages={handleReorderPages}
onTogglePage={togglePage}
onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand}
onSetStatus={() => {}}
onSetMovingPage={setMovingPage}
onDeletePage={handleDeletePage}
createRotateCommand={createRotateCommand}
createDeleteCommand={createDeleteCommand}
createSplitCommand={createSplitCommand}
pdfDocument={displayDocument!}
setPdfDocument={setEditedDocument}
splitPositions={splitPositions}
onInsertFiles={handleInsertFiles}
zoomLevel={zoomLevelParam || zoomLevel}
/>
);
}, [
selectedPageIds,
selectionMode,
movingPage,
isAnimating,
displayDocument,
fileColorIndexMap,
handleReorderPages,
togglePage,
animateReorder,
executeCommand,
setMovingPage,
handleDeletePage,
createRotateCommand,
createDeleteCommand,
createSplitCommand,
setEditedDocument,
splitPositions,
handleInsertFiles,
zoomLevel,
]);
const renderItemCallback = useCallback(
(
page: PDFPage,
index: number,
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
boxSelectedIds: string[],
clearBoxSelection: () => void,
activeDragIds: string[],
justMoved: boolean,
dragHandleProps?: any,
zoomLevelParam?: number,
) => {
gridItemRefsRef.current = refs;
const fileColorIndex = page.originalFileId ? (fileColorIndexMap.get(page.originalFileId) ?? 0) : 0;
const isBoxSelected = boxSelectedIds.includes(page.id);
return (
<PageThumbnail
key={page.id}
page={page}
index={index}
totalPages={displayDocument?.pages.length || 0}
fileColorIndex={fileColorIndex}
selectedPageIds={selectedPageIds}
selectionMode={selectionMode}
movingPage={movingPage}
isAnimating={isAnimating}
isBoxSelected={isBoxSelected}
clearBoxSelection={clearBoxSelection}
activeDragIds={activeDragIds}
justMoved={justMoved}
pageRefs={refs}
dragHandleProps={dragHandleProps}
onReorderPages={handleReorderPages}
onTogglePage={togglePage}
onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand}
onSetStatus={() => {}}
onSetMovingPage={setMovingPage}
onDeletePage={handleDeletePage}
createRotateCommand={createRotateCommand}
createDeleteCommand={createDeleteCommand}
createSplitCommand={createSplitCommand}
pdfDocument={displayDocument!}
setPdfDocument={setEditedDocument}
splitPositions={splitPositions}
onInsertFiles={handleInsertFiles}
zoomLevel={zoomLevelParam || zoomLevel}
/>
);
},
[
selectedPageIds,
selectionMode,
movingPage,
isAnimating,
displayDocument,
fileColorIndexMap,
handleReorderPages,
togglePage,
animateReorder,
executeCommand,
setMovingPage,
handleDeletePage,
createRotateCommand,
createDeleteCommand,
createSplitCommand,
setEditedDocument,
splitPositions,
handleInsertFiles,
zoomLevel,
],
);
return (
<div
@@ -597,20 +623,24 @@ const PageEditor = ({
onMouseEnter={() => setIsContainerHovered(true)}
onMouseLeave={() => setIsContainerHovered(false)}
style={{
height: '100%',
overflow: 'auto',
position: 'relative',
width: '100%',
height: "100%",
overflow: "auto",
position: "relative",
width: "100%",
}}
>
<LoadingOverlay visible={globalProcessing && !initialDocument} />
{!initialDocument && !globalProcessing && selectedFileIds.length === 0 && (
<Center h='100%'>
<Center h="100%">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📄</Text>
<Text size="lg" c="dimmed">
📄
</Text>
<Text c="dimmed">No PDF files loaded</Text>
<Text size="sm" c="dimmed">Add files to start editing pages</Text>
<Text size="sm" c="dimmed">
Add files to start editing pages
</Text>
</Stack>
</Center>
)}
@@ -623,19 +653,19 @@ const PageEditor = ({
)}
{displayDocument && (
<Box ref={gridContainerRef} p={0} pt="2rem" pb="4rem" style={{ position: 'relative' }}>
{/* Split Lines Overlay */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
zIndex: 10
}}
>
<Box ref={gridContainerRef} p={0} pt="2rem" pb="4rem" style={{ position: "relative" }}>
{/* Split Lines Overlay */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: "none",
zIndex: 10,
}}
>
{(() => {
const refsMap = gridItemRefsRef.current?.current;
const containerEl = gridContainerRef.current;
@@ -682,12 +712,12 @@ const PageEditor = ({
<div
key={`split-${position}`}
style={{
position: 'absolute',
position: "absolute",
left: `${lineLeft - containerRect.left}px`,
top: `${currentRect.top - containerRect.top}px`,
width: '1px',
width: "1px",
height: `${currentRect.height}px`,
borderLeft: '1px dashed #3b82f6',
borderLeft: "1px dashed #3b82f6",
}}
/>
);
@@ -704,18 +734,17 @@ const PageEditor = ({
selectedPageIds={selectedPageIds}
onVisibleItemsChange={handleVisibleItemsChange}
getThumbnailData={(pageId) => {
const page = displayDocument.pages.find(p => p.id === pageId);
const page = displayDocument.pages.find((p) => p.id === pageId);
if (!page?.thumbnail) return null;
return {
src: page.thumbnail,
rotation: page.rotation || 0
rotation: page.rotation || 0,
};
}}
renderItem={renderItemCallback}
/>
</Box>
)}
</div>
);
};
@@ -1,7 +1,4 @@
import {
Tooltip,
ActionIcon,
} from "@mantine/core";
import { Tooltip, ActionIcon } from "@mantine/core";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import ContentCutIcon from "@mui/icons-material/ContentCut";
@@ -21,7 +18,7 @@ interface PageEditorControlsProps {
canRedo: boolean;
// Page operations
onRotate: (direction: 'left' | 'right') => void;
onRotate: (direction: "left" | "right") => void;
onDelete: () => void;
onSplit: () => void;
onSplitAll: () => void;
@@ -64,93 +61,102 @@ const PageEditorControls = ({
const totalPages = displayDocument.pages.length;
const selectedValidPageIds = displayDocument.pages
.filter((page, index) => selectedPageIds.includes(page.id) && index < totalPages - 1)
.map(page => page.id);
.map((page) => page.id);
if (selectedValidPageIds.length === 0) {
return "Split Selected";
}
const existingSplitsCount = selectedValidPageIds.filter(id => splitPositions.has(id)).length;
const existingSplitsCount = selectedValidPageIds.filter((id) => splitPositions.has(id)).length;
const noSplitsCount = selectedValidPageIds.length - existingSplitsCount;
const willRemoveSplits = existingSplitsCount > noSplitsCount;
if (willRemoveSplits) {
return existingSplitsCount === selectedValidPageIds.length
? "Remove All Selected Splits"
: "Remove Selected Splits";
return existingSplitsCount === selectedValidPageIds.length ? "Remove All Selected Splits" : "Remove Selected Splits";
} else {
return existingSplitsCount === 0
? "Split Selected"
: "Complete Selected Splits";
return existingSplitsCount === 0 ? "Split Selected" : "Complete Selected Splits";
}
};
// Calculate page break tooltip text
const getPageBreakTooltip = () => {
return selectedPageIds.length > 0
? `Insert ${selectedPageIds.length} Page Break${selectedPageIds.length > 1 ? 's' : ''}`
? `Insert ${selectedPageIds.length} Page Break${selectedPageIds.length > 1 ? "s" : ""}`
: "Insert Page Breaks";
};
return (
<div
style={{
position: 'sticky',
position: "sticky",
left: 0,
right: 0,
bottom: 0,
zIndex: 50,
display: 'flex',
justifyContent: 'center',
pointerEvents: 'none',
background: 'transparent',
display: "flex",
justifyContent: "center",
pointerEvents: "none",
background: "transparent",
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
gap: 12,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
boxShadow: '0 -2px 8px rgba(0,0,0,0.04)',
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderRadius: '16px 16px 0 0',
pointerEvents: 'auto',
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
backgroundColor: "var(--bg-toolbar)",
border: "1px solid var(--border-default)",
borderRadius: "16px 16px 0 0",
pointerEvents: "auto",
minWidth: 360,
maxWidth: 700,
flexWrap: 'wrap',
justifyContent: 'center',
flexWrap: "wrap",
justifyContent: "center",
padding: "1rem",
paddingBottom: "1rem"
paddingBottom: "1rem",
}}
>
{/* Undo/Redo */}
<Tooltip label="Undo">
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" style={{ color: canUndo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
<ActionIcon
onClick={onUndo}
disabled={!canUndo}
variant="subtle"
style={{ color: canUndo ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
radius="md"
size="lg"
>
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Redo">
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" style={{ color: canRedo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
<ActionIcon
onClick={onRedo}
disabled={!canRedo}
variant="subtle"
style={{ color: canRedo ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
radius="md"
size="lg"
>
<RedoIcon />
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
<div style={{ width: 1, height: 28, backgroundColor: "var(--mantine-color-gray-3)", margin: "0 8px" }} />
{/* Page Operations */}
<Tooltip label="Rotate Selected Left">
<ActionIcon
onClick={() => onRotate('left')}
onClick={() => onRotate("left")}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
radius="md"
size="lg"
>
@@ -159,10 +165,10 @@ const PageEditorControls = ({
</Tooltip>
<Tooltip label="Rotate Selected Right">
<ActionIcon
onClick={() => onRotate('right')}
onClick={() => onRotate("right")}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
radius="md"
size="lg"
>
@@ -174,7 +180,7 @@ const PageEditorControls = ({
onClick={onDelete}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
radius="md"
size="lg"
>
@@ -186,7 +192,7 @@ const PageEditorControls = ({
onClick={onSplit}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
radius="md"
size="lg"
>
@@ -198,7 +204,7 @@ const PageEditorControls = ({
onClick={onPageBreak}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
radius="md"
size="lg"
>
@@ -1,7 +1,7 @@
import { ActionIcon, Popover } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip';
import BulkSelectionPanel from '@app/components/pageEditor/BulkSelectionPanel';
import { ActionIcon, Popover } from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
import BulkSelectionPanel from "@app/components/pageEditor/BulkSelectionPanel";
interface PageSelectByNumberButtonProps {
disabled: boolean;
@@ -29,7 +29,7 @@ export default function PageSelectByNumberButton({
<div className={`right-rail-fade enter`}>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
<div style={{ display: "inline-flex" }}>
<ActionIcon
variant="subtle"
radius="md"
@@ -42,7 +42,7 @@ export default function PageSelectByNumberButton({
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
<div style={{ minWidth: "24rem", maxWidth: "32rem" }}>
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
@@ -1,21 +1,20 @@
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
import { Text, Checkbox } from '@mantine/core';
import { useIsMobile } from '@app/hooks/useIsMobile';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import AddIcon from '@mui/icons-material/Add';
import { PDFPage, PDFDocument } from '@app/types/pageEditor';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors';
import styles from '@app/components/pageEditor/PageEditor.module.css';
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import React, { useCallback, useState, useEffect, useRef, useMemo } from "react";
import { Text, Checkbox } from "@mantine/core";
import { useIsMobile } from "@app/hooks/useIsMobile";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import AddIcon from "@mui/icons-material/Add";
import { PDFPage, PDFDocument } from "@app/types/pageEditor";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors";
import styles from "@app/components/pageEditor/PageEditor.module.css";
import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
interface PageThumbnailProps {
page: PDFPage;
@@ -81,7 +80,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
const isSelected = Array.isArray(selectedPageIds) ? selectedPageIds.includes(page.id) : false;
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
const [mouseStartPos, setMouseStartPos] = useState<{ x: number; y: number } | null>(null);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useIsMobile();
const lastClickTimeRef = useRef<number>(0);
@@ -96,13 +95,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
// Calculate document aspect ratio from first non-blank page
const getDocumentAspectRatio = useCallback(() => {
// Find first non-blank page with a thumbnail to get aspect ratio
const firstRealPage = pdfDocument.pages.find(p => !p.isBlankPage && p.thumbnail);
const firstRealPage = pdfDocument.pages.find((p) => !p.isBlankPage && p.thumbnail);
if (firstRealPage?.thumbnail) {
// Try to get aspect ratio from an actual thumbnail image
// For now, default to A4 but could be enhanced to measure image dimensions
return '1 / 1.414'; // A4 ratio as fallback
return "1 / 1.414"; // A4 ratio as fallback
}
return '1 / 1.414'; // Default A4 ratio
return "1 / 1.414"; // Default A4 ratio
}, [pdfDocument.pages]);
// Update thumbnail URL when page prop changes
@@ -113,77 +112,94 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
}, [page.thumbnail, thumbnailUrl]);
// Merge refs - combine our ref tracking with dnd-kit's ref
const mergedRef = useCallback((element: HTMLDivElement | null) => {
// Track in our refs map
elementRef.current = element;
if (element) {
pageRefs.current.set(page.id, element);
} else {
pageRefs.current.delete(page.id);
}
// Call dnd-kit's ref if provided
if (dragHandleProps?.ref) {
dragHandleProps.ref(element);
}
}, [page.id, pageRefs, dragHandleProps]);
const mergedRef = useCallback(
(element: HTMLDivElement | null) => {
// Track in our refs map
elementRef.current = element;
if (element) {
pageRefs.current.set(page.id, element);
} else {
pageRefs.current.delete(page.id);
}
// Call dnd-kit's ref if provided
if (dragHandleProps?.ref) {
dragHandleProps.ref(element);
}
},
[page.id, pageRefs, dragHandleProps],
);
// DOM command handlers
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// Use the command system for undo/redo support
const command = createRotateCommand([page.id], -90);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} left`);
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
const handleRotateLeft = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// Use the command system for undo/redo support
const command = createRotateCommand([page.id], -90);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} left`);
},
[page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand],
);
const handleRotateRight = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// Use the command system for undo/redo support
const command = createRotateCommand([page.id], 90);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} right`);
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
const handleRotateRight = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// Use the command system for undo/redo support
const command = createRotateCommand([page.id], 90);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} right`);
},
[page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand],
);
const handleDelete = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onDeletePage(page.pageNumber);
onSetStatus(`Deleted page ${page.pageNumber}`);
}, [page.pageNumber, onDeletePage, onSetStatus]);
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDeletePage(page.pageNumber);
onSetStatus(`Deleted page ${page.pageNumber}`);
},
[page.pageNumber, onDeletePage, onSetStatus],
);
const handleSplit = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
const handleSplit = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// Create a command to toggle split at this position
const command = createSplitCommand(page.id, page.pageNumber);
onExecuteCommand(command);
// Create a command to toggle split at this position
const command = createSplitCommand(page.id, page.pageNumber);
onExecuteCommand(command);
const hasSplit = splitPositions.has(page.id);
const action = hasSplit ? 'removed' : 'added';
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
}, [pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
const hasSplit = splitPositions.has(page.id);
const action = hasSplit ? "removed" : "added";
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
},
[pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand],
);
const handleInsertFileAfter = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
const handleInsertFileAfter = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (onInsertFiles) {
// Open file manager modal with custom handler for page insertion
openFilesModal({
insertAfterPage: page.pageNumber,
customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => {
if (insertAfterPage !== undefined) {
onInsertFiles(files, insertAfterPage, isFromStorage);
}
}
});
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
} else {
// Fallback to normal file handling
openFilesModal({ insertAfterPage: page.pageNumber });
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
}
}, [openFilesModal, page.pageNumber, onSetStatus, onInsertFiles]);
if (onInsertFiles) {
// Open file manager modal with custom handler for page insertion
openFilesModal({
insertAfterPage: page.pageNumber,
customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => {
if (insertAfterPage !== undefined) {
onInsertFiles(files, insertAfterPage, isFromStorage);
}
},
});
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
} else {
// Fallback to normal file handling
openFilesModal({ insertAfterPage: page.pageNumber });
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
}
},
[openFilesModal, page.pageNumber, onSetStatus, onInsertFiles],
);
// Handle click vs drag differentiation
const handleMouseDown = useCallback((e: React.MouseEvent) => {
@@ -191,40 +207,43 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
setMouseStartPos({ x: e.clientX, y: e.clientY });
}, []);
const handleMouseUp = useCallback((e: React.MouseEvent) => {
if (!isMouseDown || !mouseStartPos) {
setIsMouseDown(false);
setMouseStartPos(null);
return;
}
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
if (!isMouseDown || !mouseStartPos) {
setIsMouseDown(false);
setMouseStartPos(null);
return;
}
// Calculate distance moved
const deltaX = Math.abs(e.clientX - mouseStartPos.x);
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// Calculate distance moved
const deltaX = Math.abs(e.clientX - mouseStartPos.x);
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// If mouse moved less than 2 pixels, consider it a click (not a drag)
if (distance < 2 && !isDragging) {
// Prevent rapid double-clicks from causing issues (debounce with 100ms threshold)
const now = Date.now();
if (now - lastClickTimeRef.current > 100) {
lastClickTimeRef.current = now;
// If mouse moved less than 2 pixels, consider it a click (not a drag)
if (distance < 2 && !isDragging) {
// Prevent rapid double-clicks from causing issues (debounce with 100ms threshold)
const now = Date.now();
if (now - lastClickTimeRef.current > 100) {
lastClickTimeRef.current = now;
// Clear box selection when clicking on a non-selected page
if (!isBoxSelected && clearBoxSelection) {
clearBoxSelection();
}
// Clear box selection when clicking on a non-selected page
if (!isBoxSelected && clearBoxSelection) {
clearBoxSelection();
}
// Don't toggle page selection if it's box-selected (just keep the box selection)
if (!isBoxSelected) {
onTogglePage(page.id);
// Don't toggle page selection if it's box-selected (just keep the box selection)
if (!isBoxSelected) {
onTogglePage(page.id);
}
}
}
}
setIsMouseDown(false);
setMouseStartPos(null);
}, [isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage]);
setIsMouseDown(false);
setMouseStartPos(null);
},
[isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage],
);
const handleMouseLeave = useCallback(() => {
setIsMouseDown(false);
@@ -232,79 +251,96 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
setIsHovered(false);
}, []);
const fileColorBorder = page.isBlankPage ? 'transparent' : getFileColorWithOpacity(fileColorIndex, 0.3);
const fileColorBorder = page.isBlankPage ? "transparent" : getFileColorWithOpacity(fileColorIndex, 0.3);
// Spread dragHandleProps but use our merged ref
const { ref: _, ...restDragProps } = dragHandleProps || {};
// Build hover menu actions
const hoverActions = useMemo<HoverAction[]>(() => [
{
id: 'move-left',
icon: <ArrowBackIcon style={{ fontSize: 20 }} />,
label: 'Move Left',
onClick: (e) => {
e.stopPropagation();
if (pageIndex > 0 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
onReorderPages(page.pageNumber, pageIndex - 1);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} left`);
}
const hoverActions = useMemo<HoverAction[]>(
() => [
{
id: "move-left",
icon: <ArrowBackIcon style={{ fontSize: 20 }} />,
label: "Move Left",
onClick: (e) => {
e.stopPropagation();
if (pageIndex > 0 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
onReorderPages(page.pageNumber, pageIndex - 1);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} left`);
}
},
disabled: pageIndex === 0,
},
disabled: pageIndex === 0
},
{
id: 'move-right',
icon: <ArrowForwardIcon style={{ fontSize: 20 }} />,
label: 'Move Right',
onClick: (e) => {
e.stopPropagation();
if (pageIndex < totalPages - 1 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
// ReorderPagesCommand expects target index relative to the original array.
// When moving toward the right (higher index), provide desiredIndex + 1
// so the command's internal adjustment (targetIndex - 1) lands correctly.
onReorderPages(page.pageNumber, pageIndex + 2);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} right`);
}
{
id: "move-right",
icon: <ArrowForwardIcon style={{ fontSize: 20 }} />,
label: "Move Right",
onClick: (e) => {
e.stopPropagation();
if (pageIndex < totalPages - 1 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
// ReorderPagesCommand expects target index relative to the original array.
// When moving toward the right (higher index), provide desiredIndex + 1
// so the command's internal adjustment (targetIndex - 1) lands correctly.
onReorderPages(page.pageNumber, pageIndex + 2);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} right`);
}
},
disabled: pageIndex === totalPages - 1,
},
disabled: pageIndex === totalPages - 1
},
{
id: 'rotate-left',
icon: <RotateLeftIcon style={{ fontSize: 20 }} />,
label: 'Rotate Left',
onClick: handleRotateLeft,
},
{
id: 'rotate-right',
icon: <RotateRightIcon style={{ fontSize: 20 }} />,
label: 'Rotate Right',
onClick: handleRotateRight,
},
{
id: 'delete',
icon: <DeleteIcon style={{ fontSize: 20 }} />,
label: 'Delete Page',
onClick: handleDelete,
color: 'red',
},
{
id: 'split',
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
label: 'Split After',
onClick: handleSplit,
hidden: pageIndex >= totalPages - 1,
},
{
id: 'insert',
icon: <AddIcon style={{ fontSize: 20 }} />,
label: 'Insert File After',
onClick: handleInsertFileAfter,
}
], [pageIndex, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]);
{
id: "rotate-left",
icon: <RotateLeftIcon style={{ fontSize: 20 }} />,
label: "Rotate Left",
onClick: handleRotateLeft,
},
{
id: "rotate-right",
icon: <RotateRightIcon style={{ fontSize: 20 }} />,
label: "Rotate Right",
onClick: handleRotateRight,
},
{
id: "delete",
icon: <DeleteIcon style={{ fontSize: 20 }} />,
label: "Delete Page",
onClick: handleDelete,
color: "red",
},
{
id: "split",
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
label: "Split After",
onClick: handleSplit,
hidden: pageIndex >= totalPages - 1,
},
{
id: "insert",
icon: <AddIcon style={{ fontSize: 20 }} />,
label: "Insert File After",
onClick: handleInsertFileAfter,
},
],
[
pageIndex,
totalPages,
movingPage,
isAnimating,
page.pageNumber,
handleRotateLeft,
handleRotateRight,
handleDelete,
handleSplit,
handleInsertFileAfter,
onReorderPages,
onSetMovingPage,
onSetStatus,
],
);
return (
<div
@@ -315,7 +351,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
className={`
${styles.pageContainer}
!rounded-lg
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
${selectionMode ? "cursor-pointer" : "cursor-grab"}
select-none
flex items-center justify-center
flex-shrink-0
@@ -323,17 +359,17 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
hover:shadow-md
transition-all
relative
${isDragging ? 'opacity-50 scale-95' : ''}
${movingPage === page.pageNumber ? 'page-moving' : ''}
${isBoxSelected ? 'ring-4 ring-blue-400 ring-offset-2' : ''}
${isDragging ? "opacity-50 scale-95" : ""}
${movingPage === page.pageNumber ? "page-moving" : ""}
${isBoxSelected ? "ring-4 ring-blue-400 ring-offset-2" : ""}
`}
style={{
width: `calc(20rem * ${zoomLevel})`,
height: `calc(20rem * ${zoomLevel})`,
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
transition: isAnimating ? "none" : "transform 0.2s ease-in-out",
zIndex: isHovered ? 50 : 1,
...(isBoxSelected && {
boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.5)',
boxShadow: "0 0 0 4px rgba(59, 130, 246, 0.5)",
}),
}}
onMouseDown={handleMouseDown}
@@ -345,15 +381,15 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
<div
className={styles.checkboxContainer}
style={{
position: 'absolute',
position: "absolute",
top: 8,
right: 8,
zIndex: 10,
backgroundColor: 'white',
borderRadius: '4px',
padding: '2px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto'
backgroundColor: "white",
borderRadius: "4px",
padding: "2px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
pointerEvents: "auto",
}}
onMouseDown={(e) => {
e.stopPropagation();
@@ -371,41 +407,45 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
// Selection is handled by container mouseDown
}}
size="sm"
style={{ pointerEvents: 'none' }}
style={{ pointerEvents: "none" }}
/>
</div>
}
<div className="page-container w-[90%] h-[90%]" draggable={false}>
<div
className={`${styles.pageSurface} ${justMoved ? styles.pageJustMoved : ''}`}
className={`${styles.pageSurface} ${justMoved ? styles.pageJustMoved : ""}`}
style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
width: "100%",
height: "100%",
backgroundColor: "var(--mantine-color-gray-1)",
borderRadius: 6,
boxShadow: page.isBlankPage ? 'none' : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
boxShadow: page.isBlankPage ? "none" : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
padding: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{page.isBlankPage ? (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: '70%',
aspectRatio: getDocumentAspectRatio(),
backgroundColor: 'white',
border: '1px solid #e9ecef',
borderRadius: 2
}} />
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
width: "70%",
aspectRatio: getDocumentAspectRatio(),
backgroundColor: "white",
border: "1px solid #e9ecef",
borderRadius: 2,
}}
/>
</div>
) : thumbnailUrl ? (
<PrivateContent>
@@ -416,19 +456,23 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
draggable={false}
data-original-rotation={page.rotation}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
width: "100%",
height: "100%",
objectFit: "contain",
borderRadius: 2,
transform: `rotate(${page.rotation}deg)`,
transition: 'transform 0.3s ease-in-out'
transition: "transform 0.3s ease-in-out",
}}
/>
</PrivateContent>
) : (
<div style={{ textAlign: 'center' }}>
<Text size="lg" c="dimmed">📄</Text>
<Text size="xs" c="dimmed" mt={4}>Page {page.pageNumber}</Text>
<div style={{ textAlign: "center" }}>
<Text size="lg" c="dimmed">
📄
</Text>
<Text size="xs" c="dimmed" mt={4}>
Page {page.pageNumber}
</Text>
</div>
)}
</div>
@@ -438,16 +482,16 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
size="sm"
fw={500}
style={{
color: 'var(--mantine-color-white)', // Use theme token for consistency
position: 'absolute',
color: "var(--mantine-color-white)", // Use theme token for consistency
position: "absolute",
top: 5,
left: 5,
background: 'rgba(162, 201, 255, 0.8)',
padding: '6px 8px',
background: "rgba(162, 201, 255, 0.8)",
padding: "6px 8px",
borderRadius: 8,
zIndex: 2,
opacity: 0,
transition: 'opacity 0.2s ease-in-out'
transition: "opacity 0.2s ease-in-out",
}}
>
{page.pageNumber}
@@ -459,9 +503,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
position="inside"
className={styles.pageHoverControls}
/>
</div>
</div>
);
};

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