diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e431fd5f7f..643ef13fd0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 = ''; + 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 = ''; + 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 diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 764f748d44..9e4d5d574f 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -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 diff --git a/build.gradle b/build.gradle index 62e92d9484..0721c37baa 100644 --- a/build.gradle +++ b/build.gradle @@ -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') } diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000000..ad5dab21a1 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,10 @@ +dist/ +node_modules/ +public/vendor/ +public/pdfjs*/ +public/js/thirdParty/ +public/css/cookieconsent.css +*.min.* +*.md +*.wxs +src/output.css diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 9bbe913d60..de728c2775 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -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, - } - } + }, + }, }, ); diff --git a/frontend/index.html b/frontend/index.html index c790b8d1ed..465841285d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,4 +1,4 @@ - + @@ -6,10 +6,7 @@ - + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dbf6bbd484..d6a59cbed7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 93051e2dc7..167fd7181e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 14eb25f85c..deb6a63977 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -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, }, -}); \ No newline at end of file +}); diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 57e730c99a..7b8895cce8 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,3 @@ module.exports = { - plugins: [ - require('@tailwindcss/postcss'), - require('autoprefixer'), - ], + plugins: [require("@tailwindcss/postcss"), require("autoprefixer")], }; diff --git a/frontend/public/css/cookieconsentCustomisation.css b/frontend/public/css/cookieconsentCustomisation.css index ec360c20be..fd1a8ff355 100644 --- a/frontend/public/css/cookieconsentCustomisation.css +++ b/frontend/public/css/cookieconsentCustomisation.css @@ -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; -} \ No newline at end of file + color: var(--cc-primary-color) !important; +} diff --git a/frontend/public/manifest-classic.json b/frontend/public/manifest-classic.json index 9b47da7d05..d6e81e7ddf 100644 --- a/frontend/public/manifest-classic.json +++ b/frontend/public/manifest-classic.json @@ -23,4 +23,3 @@ "theme_color": "#000000", "background_color": "#ffffff" } - diff --git a/frontend/scripts/build-provisioner.mjs b/frontend/scripts/build-provisioner.mjs index 2f974a195f..52240b766e 100644 --- a/frontend/scripts/build-provisioner.mjs +++ b/frontend/scripts/build-provisioner.mjs @@ -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); diff --git a/frontend/scripts/generate-icons.js b/frontend/scripts/generate-icons.js index 96566341c6..d15c53393d 100644 --- a/frontend/scripts/generate-icons.js +++ b/frontend/scripts/generate-icons.js @@ -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: const localIconMatches = content.match(/]*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: const localIconSingleQuoteMatches = content.match(/]*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: icon-name const spanMatches = content.match(/]*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: const iconMatches = content.match(/]*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); }); diff --git a/frontend/scripts/generate-licenses.js b/frontend/scripts/generate-licenses.js index e4b40c0e42..339e208326 100644 --- a/frontend/scripts/generate-licenses.js +++ b/frontend/scripts/generate-licenses.js @@ -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 license-checker@25.0.1 --production --json', - 'npx --yes license-report --only=prod --output=json', - { - encoding: 'utf8', - cwd: path.dirname(PACKAGE_JSON), - env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: 'true' } - } - ); - try { - licenseData = JSON.parse(licenseReport); - } catch (parseError) { - console.error('āŒ Failed to parse license data:', parseError.message); - console.error('Raw output:', licenseReport.substring(0, 500) + '...'); - process.exit(1); - } - } - - if (!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 license-checker@25.0.1 --production --json', + "npx --yes license-report --only=prod --output=json", + { + encoding: "utf8", + cwd: path.dirname(PACKAGE_JSON), + env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: "true" }, + }, ); - - 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; } diff --git a/frontend/scripts/sample-pdf/generate.mjs b/frontend/scripts/sample-pdf/generate.mjs index 93e5cf7ee3..2ad477cc98 100755 --- a/frontend/scripts/sample-pdf/generate.mjs +++ b/frontend/scripts/sample-pdf/generate.mjs @@ -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); }); diff --git a/frontend/scripts/sample-pdf/styles.css b/frontend/scripts/sample-pdf/styles.css index 067452833c..7f34b95e8f 100644 --- a/frontend/scripts/sample-pdf/styles.css +++ b/frontend/scripts/sample-pdf/styles.css @@ -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; } * { diff --git a/frontend/scripts/sample-pdf/template.html b/frontend/scripts/sample-pdf/template.html index edd7f2c9f4..aea5e4cb13 100644 --- a/frontend/scripts/sample-pdf/template.html +++ b/frontend/scripts/sample-pdf/template.html @@ -1,234 +1,244 @@ - + - - - - Stirling PDF - Sample Document - - - - -
-
- - - - - -
-
-
- + + + + Stirling PDF - Sample Document + + + + +
+
+ + + + +
-

The Free Adobe Acrobat Alternative

-
-
- 10M+ - Downloads +
+
+
-
-
-
Open Source
-
Privacy First
-
Self-Hosted
-
-
-
- - -
-
-

What is Stirling PDF?

-

- 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. -

- -
-
-
- - - +

The Free Adobe Acrobat Alternative

+
+
+ 10M+ + Downloads
-

50+ PDF Operations

-

Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.

- -
-
- - - -
-

Workflow Automation

-

Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.

-
- -
-
- - - - - -
-

Multi-Language Support

-

Available in over 30 languages with community-contributed translations. Accessible to users worldwide.

-
- -
-
- - - - - -
-

Privacy First

-

Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.

-
- -
-
- - - - -
-

Open Source

-

Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.

-
- -
-
- - - - -
-

API Access

-

RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.

+
+
Open Source
+
Privacy First
+
Self-Hosted
-
- -
-
-

Key Features

+ +
+
+

What is Stirling PDF?

+

+ 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. +

-
-
-
-
+
+
+
- - - - - +
-

Page Operations

+

50+ PDF Operations

+

Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.

-
    -
  • Merge & split PDFs
  • -
  • Rearrange pages
  • -
  • Rotate & crop
  • -
  • Extract pages
  • -
  • Multi-page layout
  • -
-
-
-
-
+
+
+ + + +
+

Workflow Automation

+

Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.

+
+ +
+
- - + + +
-

Security & Signing

+

Multi-Language Support

+

Available in over 30 languages with community-contributed translations. Accessible to users worldwide.

-
    -
  • Password protection
  • -
  • Digital signatures
  • -
  • Watermarks
  • -
  • Permission controls
  • -
  • Redaction tools
  • -
-
-
-
-
- - +
+
+ + + +
-

File Conversions

+

Privacy First

+

+ Self-hosted solution means your data stays on your infrastructure. You have full control over your documents. +

-
    -
  • PDF to/from images
  • -
  • Office documents
  • -
  • HTML to PDF
  • -
  • Markdown to PDF
  • -
  • PDF to Word/Excel
  • -
-
-
-
-
- - +
+
+ + +
-

Automation

+

Open Source

+

Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.

-
    -
  • Multi-step workflows
  • -
  • Chain PDF operations
  • -
  • Save recurring tasks
  • -
  • Batch file processing
  • -
  • API integration
  • -
-
-
-
-
-
- - - +
+
+ + + + +
+

API Access

+

RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.

-

Plus Many More

-
-
-
    -
  • OCR text recognition
  • -
  • Compress PDFs
  • -
  • Add images & stamps
  • -
  • Detect blank pages
  • -
  • Extract images
  • -
  • Edit metadata
  • -
-
    -
  • Flatten forms
  • -
  • PDF/A conversion
  • -
  • Add page numbers
  • -
  • Remove pages
  • -
  • Repair PDFs
  • -
  • And 40+ more tools
  • -
-
- + +
+
+

Key Features

+ +
+
+
+
+ + + + + + + +
+

Page Operations

+
+
    +
  • Merge & split PDFs
  • +
  • Rearrange pages
  • +
  • Rotate & crop
  • +
  • Extract pages
  • +
  • Multi-page layout
  • +
+
+ +
+
+
+ + + + +
+

Security & Signing

+
+
    +
  • Password protection
  • +
  • Digital signatures
  • +
  • Watermarks
  • +
  • Permission controls
  • +
  • Redaction tools
  • +
+
+ +
+
+
+ + + +
+

File Conversions

+
+
    +
  • PDF to/from images
  • +
  • Office documents
  • +
  • HTML to PDF
  • +
  • Markdown to PDF
  • +
  • PDF to Word/Excel
  • +
+
+ +
+
+
+ + + +
+

Automation

+
+
    +
  • Multi-step workflows
  • +
  • Chain PDF operations
  • +
  • Save recurring tasks
  • +
  • Batch file processing
  • +
  • API integration
  • +
+
+
+ +
+
+
+ + + +
+

Plus Many More

+
+
+
    +
  • OCR text recognition
  • +
  • Compress PDFs
  • +
  • Add images & stamps
  • +
  • Detect blank pages
  • +
  • Extract images
  • +
  • Edit metadata
  • +
+
    +
  • Flatten forms
  • +
  • PDF/A conversion
  • +
  • Add page numbers
  • +
  • Remove pages
  • +
  • Repair PDFs
  • +
  • And 40+ more tools
  • +
+
+
+
+
+ diff --git a/frontend/scripts/setup-env.ts b/frontend/scripts/setup-env.ts index 00ec03df00..508a3ee19a 100644 --- a/frontend/scripts/setup-env.ts +++ b/frontend/scripts/setup-env.ts @@ -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.", ); } diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 6acaac5145..9259e4543f 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -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", diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 10203960c0..536d0d382d 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -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"] + } + } + } } diff --git a/frontend/src/assets/3rdPartyLicenses.json b/frontend/src/assets/3rdPartyLicenses.json index 392249d420..baf60879b6 100644 --- a/frontend/src/assets/3rdPartyLicenses.json +++ b/frontend/src/assets/3rdPartyLicenses.json @@ -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://git@github.com/i18next/i18next-http-backend.git", - "moduleVersion": "3.0.2", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+ssh://git@github.com/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" - } - ] -} \ No newline at end of file + "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://git@github.com/i18next/i18next-http-backend.git", + "moduleVersion": "3.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+ssh://git@github.com/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" + } + ] +} diff --git a/frontend/src/core/App.tsx b/frontend/src/core/App.tsx index f53031c4ba..c417ef1f65 100644 --- a/frontend/src/core/App.tsx +++ b/frontend/src/core/App.tsx @@ -21,9 +21,7 @@ import "@app/utils/fileIdSafety"; function MobileScannerProviders({ children }: { children: React.ReactNode }) { return ( - - {children} - + {children} ); } diff --git a/frontend/src/core/components/AppLayout.tsx b/frontend/src/core/components/AppLayout.tsx index 9bcd31e6db..328758cf3a 100644 --- a/frontend/src/core/components/AppLayout.tsx +++ b/frontend/src/core/components/AppLayout.tsx @@ -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; } `} -
+
{banner} -
- {children} -
+
{children}
diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 75c7d281c3..3080f946c0 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -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(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; +type AppConfigProviderOverrides = Omit; export interface AppProvidersProps { children: ReactNode; @@ -98,49 +103,44 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - {children} - + {children} - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/frontend/src/core/components/FileManager.tsx b/frontend/src/core/components/FileManager.tsx index e3c9a84661..fe140ab173 100644 --- a/frontend/src/core/components/FileManager.tsx +++ b/frontend/src/core/components/FileManager.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 ( = ({ 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" }, }} > -
+
setIsDragging(true)} @@ -165,14 +179,14 @@ const FileManager: React.FC = ({ 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" }, }} > void; } -const StorageStatsCard: React.FC = ({ - storageStats, - filesCount, - onClearAll, - onReloadFiles, -}) => { +const StorageStatsCard: React.FC = ({ storageStats, filesCount, onClearAll, onReloadFiles }) => { const { t } = useTranslation(); if (!storageStats) return null; @@ -59,12 +54,7 @@ const StorageStatsCard: React.FC = ({ {t("fileManager.clearAll", "Clear All")} )} - @@ -73,4 +63,4 @@ const StorageStatsCard: React.FC = ({ ); }; -export default StorageStatsCard; \ No newline at end of file +export default StorageStatsCard; diff --git a/frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx b/frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx index 0979d59e35..d1c8f5c087 100644 --- a/frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx +++ b/frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx @@ -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 = ({ getImageData, isPlacementMode, signatureConfig, - setSignatureConfig + setSignatureConfig, }) => { const contextValue: PDFAnnotationContextValue = { activateDrawMode, @@ -72,20 +72,16 @@ export const PDFAnnotationProvider: React.FC = ({ getImageData, isPlacementMode, signatureConfig, - setSignatureConfig + setSignatureConfig, }; - return ( - - {children} - - ); + return {children}; }; 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; -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx index ea093b5be8..07a441abd5 100644 --- a/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx +++ b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx @@ -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 = ({ 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(null); const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false }); @@ -94,14 +90,12 @@ export const BaseAnnotationTool: React.FC = ({ signatureData, onSignatureDataChange: handleSignatureDataChange, onColorSwatchClick: () => setIsColorPickerOpen(true), - disabled + disabled, })} {/* Instructions for placing signature */} - - - Click anywhere on the PDF to place your annotation. - + + Click anywhere on the PDF to place your annotation. {/* Color Picker Modal */} diff --git a/frontend/src/core/components/annotation/shared/ColorControl.tsx b/frontend/src/core/components/annotation/shared/ColorControl.tsx index 16b3f845bb..66ff868fd0 100644 --- a/frontend/src/core/components/annotation/shared/ColorControl.tsx +++ b/frontend/src/core/components/annotation/shared/ColorControl.tsx @@ -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 && ( - + diff --git a/frontend/src/core/components/annotation/shared/ColorPicker.tsx b/frontend/src/core/components/annotation/shared/ColorPicker.tsx index 21656b1f23..71f326a7d5 100644 --- a/frontend/src/core/components/annotation/shared/ColorPicker.tsx +++ b/frontend/src/core/components/annotation/shared/ColorPicker.tsx @@ -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 = ({ 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 ( - + {showOpacity && onOpacityChange && opacity !== undefined && ( - {resolvedOpacityLabel} + + {resolvedOpacityLabel} + )} - + @@ -80,18 +74,6 @@ interface ColorSwatchButtonProps { size?: number; } -export const ColorSwatchButton: React.FC = ({ - color, - onClick, - size = 24 -}) => { - return ( - - ); +export const ColorSwatchButton: React.FC = ({ color, onClick, size = 24 }) => { + return ; }; diff --git a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx index fd8864be26..080a05c83a 100644 --- a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx @@ -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 = ({ 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 = ({ }, [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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ - {t('sign.canvas.heading', 'Draw your signature')} - + {t("sign.canvas.heading", "Draw your signature")} + - {t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')} + {t("sign.canvas.clickToOpen", "Click to open the drawing canvas")} - + - {t('sign.canvas.colorLabel', 'Colour')} + {t("sign.canvas.colorLabel", "Colour")} - + - {t('sign.canvas.penSizeLabel', 'Pen size')} + {t("sign.canvas.penSizeLabel", "Pen size")} = ({ 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" }} /> @@ -286,26 +292,24 @@ export const DrawingCanvas: React.FC = ({ 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", }} /> -
+
- +
diff --git a/frontend/src/core/components/annotation/shared/DrawingControls.tsx b/frontend/src/core/components/annotation/shared/DrawingControls.tsx index 3c28a594e0..9de5e45b9a 100644 --- a/frontend/src/core/components/annotation/shared/DrawingControls.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingControls.tsx @@ -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 = ({ return ( {onUndo && ( - + - + )} {onRedo && ( - + - + )} @@ -67,13 +67,7 @@ export const DrawingControls: React.FC = ({ {/* Place Signature Button */} {showPlaceButton && onPlaceSignature && ( - )} diff --git a/frontend/src/core/components/annotation/shared/ImageUploader.tsx b/frontend/src/core/components/annotation/shared/ImageUploader.tsx index ee2cdd123f..86bbbe7b96 100644 --- a/frontend/src/core/components/annotation/shared/ImageUploader.tsx +++ b/frontend/src/core/components/annotation/shared/ImageUploader.tsx @@ -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 = ({ placeholder, hint, allowBackgroundRemoval = false, - onProcessedImageData + onProcessedImageData, }) => { const { t } = useTranslation(); const [removeBackground, setRemoveBackground] = useState(false); @@ -36,15 +36,18 @@ export const ImageUploader: React.FC = ({ 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 = ({ } } 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ = ({ {allowBackgroundRemoval && ( handleBackgroundRemovalChange(event.currentTarget.checked)} disabled={disabled || !currentFile || isProcessing} @@ -252,7 +260,7 @@ export const ImageUploader: React.FC = ({ )} {isProcessing && ( - {t('sign.image.processing', 'Processing image...')} + {t("sign.image.processing", "Processing image...")} )} diff --git a/frontend/src/core/components/annotation/shared/OpacityControl.tsx b/frontend/src/core/components/annotation/shared/OpacityControl.tsx index 27b1f10dd9..914d4f038d 100644 --- a/frontend/src/core/components/annotation/shared/OpacityControl.tsx +++ b/frontend/src/core/components/annotation/shared/OpacityControl.tsx @@ -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 ( - + - {t('annotation.opacity', 'Opacity')} + {t("annotation.opacity", "Opacity")} - `${val}%`} - /> + `${val}%`} /> diff --git a/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx index 2f5d424ab6..8828f3b937 100644 --- a/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx +++ b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx @@ -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 */}
- {t('annotation.fontSize', 'Font size')} + {t("annotation.fontSize", "Font size")} - {t('annotation.opacity', 'Opacity')} + {t("annotation.opacity", "Opacity")} - {t('annotation.textAlignment', 'Text Alignment')} + {t("annotation.textAlignment", "Text Alignment")} onUpdate({ textAlign: 0 })} size="md" > onUpdate({ textAlign: 1 })} size="md" > onUpdate({ textAlign: 2 })} size="md" > @@ -125,7 +120,7 @@ export function PropertiesPopover({ {/* Opacity */}
- {t('annotation.opacity', 'Opacity')} + {t("annotation.opacity", "Opacity")}
- {t('annotation.strokeWidth', 'Stroke')} + {t("annotation.strokeWidth", "Stroke")}
@@ -188,7 +181,7 @@ export function PropertiesPopover({ return ( - + - {(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()} - {annotationType === 'shape' && renderShapeControls()} + {(annotationType === "text" || annotationType === "note") && renderTextNoteControls()} + {annotationType === "shape" && renderShapeControls()} ); diff --git a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx index 2385357171..59bcc57564 100644 --- a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx +++ b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx @@ -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 = ({ 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 = ({ fontSizeLabel, fontSizePlaceholder, colorLabel, - onAnyChange + onAnyChange, }) => { const { t } = useTranslation(); const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString()); @@ -61,14 +61,37 @@ export const TextInputWithFont: React.FC = ({ }, [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 = ({ label={fontLabel} value={fontFamily} onChange={(value) => { - onFontFamilyChange(value || 'Helvetica'); + onFontFamilyChange(value || "Helvetica"); onAnyChange?.(); }} data={fontOptions} @@ -187,7 +210,7 @@ export const TextInputWithFont: React.FC = ({ setColorInput(textColor); } }} - style={{ width: '100%' }} + style={{ width: "100%" }} rightSection={ !disabled && setIsColorPickerOpen(true)} @@ -195,9 +218,9 @@ export const TextInputWithFont: React.FC = ({ 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 = ({ { - 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" }, ]} /> )} diff --git a/frontend/src/core/components/annotation/shared/WidthControl.tsx b/frontend/src/core/components/annotation/shared/WidthControl.tsx index b99d35c996..58553e64b8 100644 --- a/frontend/src/core/components/annotation/shared/WidthControl.tsx +++ b/frontend/src/core/components/annotation/shared/WidthControl.tsx @@ -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 ( - + - {t('annotation.width', 'Width')} + {t("annotation.width", "Width")} - `${val}pt`} - /> + `${val}pt`} /> diff --git a/frontend/src/core/components/annotation/tools/DrawingTool.tsx b/frontend/src/core/components/annotation/tools/DrawingTool.tsx index f3643de35c..2699418eed 100644 --- a/frontend/src/core/components/annotation/tools/DrawingTool.tsx +++ b/frontend/src/core/components/annotation/tools/DrawingTool.tsx @@ -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 = ({ - onDrawingChange, - disabled = false -}) => { - const [selectedColor] = useState('#000000'); +export const DrawingTool: React.FC = ({ 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 ( - + = ({ ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/annotation/tools/ImageTool.tsx b/frontend/src/core/components/annotation/tools/ImageTool.tsx index 0704546965..fa59e272e7 100644 --- a/frontend/src/core/components/annotation/tools/ImageTool.tsx +++ b/frontend/src/core/components/annotation/tools/ImageTool.tsx @@ -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 = ({ - onImageChange, - disabled = false -}) => { +export const ImageTool: React.FC = ({ onImageChange, disabled = false }) => { const [, setImageData] = useState(null); const handleImageUpload = async (file: File | null) => { @@ -23,7 +20,7 @@ export const ImageTool: React.FC = ({ 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 = ({ 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 = ({ const toolConfig = { enableImageUpload: true, showPlaceButton: true, - placeButtonText: "Place Image" + placeButtonText: "Place Image", }; return ( - + = ({ ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/fileEditor/AddFileCard.tsx b/frontend/src/core/components/fileEditor/AddFileCard.tsx index c5cafd7561..8e321cc62c 100644 --- a/frontend/src/core/components/fileEditor/AddFileCard.tsx +++ b/frontend/src/core/components/fileEditor/AddFileCard.tsx @@ -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(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" }} />
{ - 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 */}
- -
-
- {t('fileEditor.addFiles', 'Add Files')} +
+
{t("fileEditor.addFiles", "Add Files")}
@@ -99,84 +93,81 @@ const AddFileCard = ({ {/* Stirling PDF Branding */} Stirling PDF {/* Add Files + Native Upload Buttons - styled like LandingPage */}
setIsUploadHover(false)} >
{/* Instruction Text */} {terminology.dropFilesHere} diff --git a/frontend/src/core/components/fileEditor/FileEditor.module.css b/frontend/src/core/components/fileEditor/FileEditor.module.css index 4f26c8bce5..43ef0d1af9 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.module.css +++ b/frontend/src/core/components/fileEditor/FileEditor.module.css @@ -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; } /* ========================= diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index c230495e1a..64a64b6c46 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -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(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(() => { 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 ( - + + {activeStirlingFileStubs.length === 0 ? ( +
+ + + šŸ“ + + No files loaded + + Upload PDF files, ZIP archives, or load from storage to get started + + +
+ ) : ( +
+ {/* Add File Card - only show when files exist */} + {activeStirlingFileStubs.length > 0 && } + {activeStirlingFileStubs.map((record, index) => { + return ( + + ); + })} +
+ )} +
- {activeStirlingFileStubs.length === 0 ? ( -
- - šŸ“ - No files loaded - Upload PDF files, ZIP archives, or load from storage to get started - -
- ) : ( -
- {/* Add File Card - only show when files exist */} - {activeStirlingFileStubs.length > 0 && ( - - )} - - {activeStirlingFileStubs.map((record, index) => { - return ( - - ); - })} -
- )} -
- - {/* File Picker Modal */} - setShowFilePickerModal(false)} - storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent - onSelectFiles={handleLoadFromStorage} - /> - - + {/* File Picker Modal */} + setShowFilePickerModal(false)} + storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent + onSelectFiles={handleLoadFromStorage} + />
); diff --git a/frontend/src/core/components/fileEditor/FileEditorFileName.tsx b/frontend/src/core/components/fileEditor/FileEditorFileName.tsx index a3e4cd4493..82911a9339 100644 --- a/frontend/src/core/components/fileEditor/FileEditorFileName.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorFileName.tsx @@ -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) => ( - {file.name} -); +const FileEditorFileName = ({ file }: FileEditorFileNameProps) => {file.name}; export default FileEditorFileName; diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index a2973bb7a7..307b498f26 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -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(() => [ - { - id: 'view', - icon: , - label: t('openInViewer', 'Open in Viewer'), - onClick: (e) => { - e.stopPropagation(); - onViewFile(file.id); + const hoverActions = useMemo( + () => [ + { + id: "view", + icon: , + label: t("openInViewer", "Open in Viewer"), + onClick: (e) => { + e.stopPropagation(); + onViewFile(file.id); + }, }, - }, - { - id: 'download', - icon: , - label: terminology.download, - onClick: (e) => { - e.stopPropagation(); - onDownloadFile(file.id); + { + id: "download", + icon: , + label: terminology.download, + onClick: (e) => { + e.stopPropagation(); + onDownloadFile(file.id); + }, }, - }, - ...(canUpload || canShare - ? [ - ...(canUpload ? [{ - id: 'upload', - icon: , - 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: , - label: t('fileManager.share', 'Share'), - onClick: (e: React.MouseEvent) => { - e.stopPropagation(); - setShowShareModal(true); - }, - }] : []), - ] - : []), - { - id: 'unzip', - icon: , - 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: , + 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: , + label: t("fileManager.share", "Share"), + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + setShowShareModal(true); + }, + }, + ] + : []), + ] + : []), + { + id: "unzip", + icon: , + 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: , - label: t('close', 'Close'), - onClick: (e) => { - e.stopPropagation(); - handleCloseWithConfirmation(); + { + id: "close", + icon: , + 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 (
{/* Header bar */} -
+
{/* Logo/checkbox area */}
{hasError ? (
- {t('error._value', 'Error')} + {t("error._value", "Error")}
) : isSupported ? ( ) : (
- - {t('unsupported', 'Unsupported')} - + {t("unsupported", "Unsupported")}
)}
@@ -412,9 +409,9 @@ const FileEditorThumbnail = ({ {/* Action buttons group */}
{isEncrypted && ( - + { @@ -427,9 +424,17 @@ const FileEditorThumbnail = ({ )} {/* Pin/Unpin icon */} - + + style={{ + padding: "0.5rem", + textAlign: "center", + background: "var(--file-card-bg)", + marginTop: "0.5rem", + marginBottom: "0.5rem", + }} + > {truncateCenter(file.name, 40)} - + {/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */} {`v${file.versionNumber} - `} {dateLabel} - {extUpper ? ` - ${extUpper} file` : ''} - {pageLabel ? ` - ${pageLabel}` : ''} + {extUpper ? ` - ${extUpper} file` : ""} + {pageLabel ? ` - ${pageLabel}` : ""}
{/* Preview area */}
{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", + }} + /> - ) : file.type?.startsWith('application/pdf') ? ( - + ) : file.type?.startsWith("application/pdf") ? ( + - Loading thumbnail... + + Loading thumbnail... + ) : null}
@@ -527,74 +529,72 @@ const FileEditorThumbnail = ({ {/* Tool chain display at bottom */} {file.toolHistory && ( -
+
)}
{/* Hover Menu */} - + {/* Close Confirmation Modal */} {file.isDirty && file.localFilePath ? ( <> - {t('confirmCloseUnsaved', 'This file has unsaved changes.')} + {t("confirmCloseUnsaved", "This file has unsaved changes.")} {file.name} ) : ( <> - {t('confirmCloseMessage', 'Are you sure you want to close this file?')} + {t("confirmCloseMessage", "Are you sure you want to close this file?")} {file.name} @@ -604,39 +604,27 @@ const FileEditorThumbnail = ({ setShowSharedEditNotice(false)} - title={t('fileManager.sharedEditNoticeTitle', 'Read-only server copy')} + title={t("fileManager.sharedEditNoticeTitle", "Read-only server copy")} centered size="auto" > {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.", )} - {canUpload && ( - setShowUploadModal(false)} - file={file} - /> - )} - {canShare && ( - setShowShareModal(false)} - file={file} - /> - )} + {canUpload && setShowUploadModal(false)} file={file} />} + {canShare && setShowShareModal(false)} file={file} />}
); }; diff --git a/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx b/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx index 122023af6f..95de665902 100644 --- a/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx +++ b/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx @@ -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(() => [ - { - id: 'file-select-all', - icon: , - 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: , - 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: , - 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( + () => [ + { + id: "file-select-all", + icon: , + 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: , + 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: , + 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); } diff --git a/frontend/src/core/components/fileManager/CompactFileDetails.tsx b/frontend/src/core/components/fileManager/CompactFileDetails.tsx index 5156dccff9..9e60b8d8e6 100644 --- a/frontend/src/core/components/fileManager/CompactFileDetails.tsx +++ b/frontend/src/core/components/fileManager/CompactFileDetails.tsx @@ -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 = ({ 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 ( - + {/* Compact mobile layout */} - + {/* Small preview */} - + {currentFile && thumbnail ? ( {currentFile.name} ) : currentFile ? ( -
- +
+
) : null} @@ -77,10 +86,10 @@ const CompactFileDetails: React.FC = ({ {/* File info */} - {currentFile ? currentFile.name : 'No file selected'} + {currentFile ? currentFile.name : "No file selected"} - {currentFile ? getFileSize(currentFile) : ''} + {currentFile ? getFileSize(currentFile) : ""} {selectedFiles.length > 1 && ` • ${selectedFiles.length} files`} {currentFile && ` • v${currentFile.versionNumber || 1}`} @@ -92,33 +101,23 @@ const CompactFileDetails: React.FC = ({ {/* Compact tool chain for mobile */} {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && ( - {currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(' → ')} + {currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(" → ")} )} {currentFile && showOwner && ( - {t('fileManager.owner', 'Owner')}: {ownerLabel} + {t("fileManager.owner", "Owner")}: {ownerLabel} )} {/* Navigation arrows for multiple files */} {hasMultipleFiles && ( - - + + - + @@ -132,14 +131,13 @@ const CompactFileDetails: React.FC = ({ 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")} ); diff --git a/frontend/src/core/components/fileManager/DesktopLayout.tsx b/frontend/src/core/components/fileManager/DesktopLayout.tsx index 9926592c84..057b70c021 100644 --- a/frontend/src/core/components/fileManager/DesktopLayout.tsx +++ b/frontend/src/core/components/fileManager/DesktopLayout.tsx @@ -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 ( - + {/* Column 1: File Sources */} - + {/* Column 2: File List */} - -
- {activeSource === 'recent' && ( + +
+ {activeSource === "recent" && ( <> -
+
-
+
)} -
+
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, }} />
@@ -81,14 +87,18 @@ const DesktopLayout: React.FC = () => { {/* Column 3: File Details */} - -
+ +
diff --git a/frontend/src/core/components/fileManager/DragOverlay.tsx b/frontend/src/core/components/fileManager/DragOverlay.tsx index 976bb940e9..de3409d90d 100644 --- a/frontend/src/core/components/fileManager/DragOverlay.tsx +++ b/frontend/src/core/components/fileManager/DragOverlay.tsx @@ -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 = ({ isVisible }) => { return (
- + - {t('fileManager.dropFilesHere', 'Drop files here to upload')} + {t("fileManager.dropFilesHere", "Drop files here to upload")}
); }; -export default DragOverlay; \ No newline at end of file +export default DragOverlay; diff --git a/frontend/src/core/components/fileManager/EmptyFilesState.tsx b/frontend/src/core/components/fileManager/EmptyFilesState.tsx index 24e79fb120..b774f06042 100644 --- a/frontend/src/core/components/fileManager/EmptyFilesState.tsx +++ b/frontend/src/core/components/fileManager/EmptyFilesState.tsx @@ -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 (
{/* Container */}
{/* No Recent Files Message */} - + - {t('fileManager.noRecentFiles', 'No recent files')} + {t("fileManager.noRecentFiles", "No recent files")} {/* Stirling PDF Logo */} Stirling PDF {/* Upload Button */}
setIsUploadHover(false)} >
{/* Instruction Text */} - + {terminology.dropFilesHere}
diff --git a/frontend/src/core/components/fileManager/FileActions.tsx b/frontend/src/core/components/fileManager/FileActions.tsx index 2e69c32d35..e39113fe6d 100644 --- a/frontend/src/core/components/fileManager/FileActions.tsx +++ b/frontend/src/core/components/fileManager/FileActions.tsx @@ -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 = () => { - onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe") - } + onChange={(value) => onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")} data={storageFilterOptions} /> )} diff --git a/frontend/src/core/components/fileManager/FileDetails.tsx b/frontend/src/core/components/fileManager/FileDetails.tsx index c2afc47769..969b98e699 100644 --- a/frontend/src/core/components/fileManager/FileDetails.tsx +++ b/frontend/src/core/components/fileManager/FileDetails.tsx @@ -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 = ({ - compact = false -}) => { +const FileDetails: React.FC = ({ compact = false }) => { const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext(); const { t } = useTranslation(); const [currentFileIndex, setCurrentFileIndex] = useState(0); @@ -35,7 +33,7 @@ const FileDetails: React.FC = ({ 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 = ({ 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 = ({ return ( {/* Section 1: Thumbnail Preview */} - + = ({ {/* Section 2: File Details */} - + ); diff --git a/frontend/src/core/components/fileManager/FileHistoryGroup.tsx b/frontend/src/core/components/fileManager/FileHistoryGroup.tsx index 75d8f0e50d..58edbc46cc 100644 --- a/frontend/src/core/components/fileManager/FileHistoryGroup.tsx +++ b/frontend/src/core/components/fileManager/FileHistoryGroup.tsx @@ -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 = ({ // 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 = ({ - {t('fileManager.fileHistory', 'File History')} ({sortedHistory.length}) + {t("fileManager.fileHistory", "File History")} ({sortedHistory.length}) diff --git a/frontend/src/core/components/fileManager/FileInfoCard.tsx b/frontend/src/core/components/fileManager/FileInfoCard.tsx index 182fcded9d..1c47c71bd2 100644 --- a/frontend/src/core/components/fileManager/FileInfoCard.tsx +++ b/frontend/src/core/components/fileManager/FileInfoCard.tsx @@ -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 = ({ - currentFile, - modalHeight -}) => { +const FileInfoCard: React.FC = ({ currentFile, modalHeight }) => { const { t } = useTranslation(); const { config } = useAppConfig(); const { onMakeCopy } = useFileManagerContext(); @@ -43,38 +40,53 @@ const FileInfoCard: React.FC = ({ 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 ( - - + + - {t('fileManager.details', 'File Details')} + {t("fileManager.details", "File Details")} - {t('fileManager.fileName', 'Name')} + {t("fileManager.fileName", "Name")} - - {currentFile ? currentFile.name : ''} + + {currentFile ? currentFile.name : ""} - {t('fileManager.fileFormat', 'Format')} + + {t("fileManager.fileFormat", "Format")} + {currentFile ? ( {detectFileExtension(currentFile.name).toUpperCase()} @@ -86,38 +98,48 @@ const FileInfoCard: React.FC = ({ - {t('fileManager.fileSize', 'Size')} + + {t("fileManager.fileSize", "Size")} + - {currentFile ? getFileSize(currentFile) : ''} + {currentFile ? getFileSize(currentFile) : ""} - {t('fileManager.lastModified', 'Last modified')} + + {t("fileManager.lastModified", "Last modified")} + - {currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ''} + {currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ""} - {t('fileManager.fileVersion', 'Version')} - {currentFile && - - v{currentFile ? (currentFile.versionNumber || 1) : ''} - } - + + {t("fileManager.fileVersion", "Version")} + + {currentFile && ( + + v{currentFile ? currentFile.versionNumber || 1 : ""} + + )} {sharingEnabled && isSharedWithYou && ( <> - {t('fileManager.owner', 'Owner')} + + {t("fileManager.owner", "Owner")} + - {ownerLabel} + + {ownerLabel} + - {t('fileManager.sharedWithYou', 'Shared with you')} + {t("fileManager.sharedWithYou", "Shared with you")} @@ -129,12 +151,10 @@ const FileInfoCard: React.FC = ({ <> - {t('fileManager.toolChain', 'Tools Applied')} - + + {t("fileManager.toolChain", "Tools Applied")} + + )} @@ -142,13 +162,8 @@ const FileInfoCard: React.FC = ({ {currentFile && isSharedWithYou && ( <> - )} @@ -157,39 +172,42 @@ const FileInfoCard: React.FC = ({ <> - {t('fileManager.cloudFile', 'Cloud file')} + + {t("fileManager.cloudFile", "Cloud file")} + {uploadEnabled && isOutOfSync ? ( - {t('fileManager.changesNotUploaded', 'Changes not uploaded')} + {t("fileManager.changesNotUploaded", "Changes not uploaded")} ) : uploadEnabled ? ( - {t('fileManager.synced', 'Synced')} + {t("fileManager.synced", "Synced")} ) : null} {lastSyncedLabel && ( - {t('fileManager.lastSynced', 'Last synced')} - {lastSyncedLabel} + + {t("fileManager.lastSynced", "Last synced")} + + + {lastSyncedLabel} + )} {isSharedByYou && sharingEnabled && ( <> - {t('fileManager.sharing', 'Sharing')} + + {t("fileManager.sharing", "Sharing")} + - {t('fileManager.sharedByYou', 'Shared by you')} + {t("fileManager.sharedByYou", "Shared by you")} - )} @@ -199,9 +217,11 @@ const FileInfoCard: React.FC = ({ <> - {t('fileManager.storageState', 'Storage')} + + {t("fileManager.storageState", "Storage")} + - {t('fileManager.localOnly', 'Local only')} + {t("fileManager.localOnly", "Local only")} diff --git a/frontend/src/core/components/fileManager/FileListArea.tsx b/frontend/src/core/components/fileManager/FileListArea.tsx index 1964dad542..b088a55d53 100644 --- a/frontend/src/core/components/fileManager/FileListArea.tsx +++ b/frontend/src/core/components/fileManager/FileListArea.tsx @@ -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 = ({ - scrollAreaHeight, - scrollAreaStyle = {}, -}) => { +const FileListArea: React.FC = ({ scrollAreaHeight, scrollAreaStyle = {} }) => { const { activeSource, recentFiles, @@ -34,12 +31,12 @@ const FileListArea: React.FC = ({ } = useFileManagerContext(); const { t } = useTranslation(); - if (activeSource === 'recent') { + if (activeSource === "recent") { return ( = ({ {recentFiles.length === 0 && !isLoading ? ( ) : recentFiles.length === 0 && isLoading ? ( -
- {t('fileManager.loadingFiles', 'Loading files...')} +
+ + {t("fileManager.loadingFiles", "Loading files...")} +
) : ( filteredFiles.map((file, index) => { @@ -93,10 +92,12 @@ const FileListArea: React.FC = ({ // Google Drive placeholder return ( -
+
- - {t('fileManager.googleDriveNotAvailable', 'Google Drive integration coming soon')} + + + {t("fileManager.googleDriveNotAvailable", "Google Drive integration coming soon")} +
); diff --git a/frontend/src/core/components/fileManager/FileListItem.tsx b/frontend/src/core/components/fileManager/FileListItem.tsx index 73ae8bd2a2..02c3c07f7d 100644 --- a/frontend/src/core/components/fileManager/FileListItem.tsx +++ b/frontend/src/core/components/fileManager/FileListItem.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ } 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 = ({ onSelect(e.shiftKey)} onDoubleClick={onDoubleClick} @@ -186,8 +184,8 @@ const FileListItem: React.FC = ({ color={isActive ? "green" : undefined} styles={{ input: { - cursor: isActive ? 'not-allowed' : 'pointer' - } + cursor: isActive ? "not-allowed" : "pointer", + }, }} /> @@ -203,12 +201,12 @@ const FileListItem: React.FC = ({ 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")} )} @@ -216,44 +214,33 @@ const FileListItem: React.FC = ({ {sharingEnabled && isSharedWithYou ? ( - {t('fileManager.sharedWithYou', 'Shared with you')} + {t("fileManager.sharedWithYou", "Shared with you")} ) : null} - {sharingEnabled && isSharedWithYou && accessRole && accessRole !== 'editor' ? ( + {sharingEnabled && isSharedWithYou && accessRole && accessRole !== "editor" ? ( - {accessRole === 'commenter' - ? t('storageShare.roleCommenter', 'Commenter') - : t('storageShare.roleViewer', 'Viewer')} + {accessRole === "commenter" + ? t("storageShare.roleCommenter", "Commenter") + : t("storageShare.roleViewer", "Viewer")} ) : isLocalOnly ? ( - {t('fileManager.localOnly', 'Local only')} + {t("fileManager.localOnly", "Local only")} ) : uploadEnabled && isOutOfSync ? ( - } - > - {t('fileManager.changesNotUploaded', 'Changes not uploaded')} + }> + {t("fileManager.changesNotUploaded", "Changes not uploaded")} ) : uploadEnabled && isUploaded ? ( - } - > - {t('fileManager.synced', 'Synced')} + }> + {t("fileManager.synced", "Synced")} ) : null} {sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && ( - {t('fileManager.sharedByYou', 'Shared by you')} + {t("fileManager.sharedByYou", "Shared by you")} )} - @@ -262,12 +249,7 @@ const FileListItem: React.FC = ({ {/* Tool chain for processed files */} {file.toolHistory && file.toolHistory.length > 0 && ( - + )} @@ -288,9 +270,9 @@ const FileListItem: React.FC = ({ 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", }} > @@ -308,7 +290,7 @@ const FileListItem: React.FC = ({ removeFiles([file.id]); }} > - {t('fileManager.closeFile', 'Close File')} + {t("fileManager.closeFile", "Close File")} @@ -322,7 +304,7 @@ const FileListItem: React.FC = ({ onDownload?.(); }} > - {t('fileManager.download', 'Download')} + {t("fileManager.download", "Download")} )} @@ -335,8 +317,8 @@ const FileListItem: React.FC = ({ }} > {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")} )} @@ -348,7 +330,7 @@ const FileListItem: React.FC = ({ setShowShareModal(true); }} > - {t('fileManager.share', 'Share')} + {t("fileManager.share", "Share")} )} @@ -360,7 +342,7 @@ const FileListItem: React.FC = ({ void handleCopyShareLink(); }} > - {t('storageShare.copyLink', 'Copy share link')} + {t("storageShare.copyLink", "Copy share link")} )} @@ -372,7 +354,7 @@ const FileListItem: React.FC = ({ setShowShareManageModal(true); }} > - {t('storageShare.manage', 'Manage sharing')} + {t("storageShare.manage", "Manage sharing")} )} @@ -380,20 +362,13 @@ const FileListItem: React.FC = ({ {isLatestVersion && hasVersionHistory && ( <> - } + leftSection={} 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")} @@ -408,7 +383,7 @@ const FileListItem: React.FC = ({ e.stopPropagation(); }} > - {t('fileManager.restore', 'Restore')} + {t("fileManager.restore", "Restore")} @@ -424,7 +399,7 @@ const FileListItem: React.FC = ({ onUnzipFile(file); }} > - {t('fileManager.unzip', 'Unzip')} + {t("fileManager.unzip", "Unzip")} @@ -437,14 +412,13 @@ const FileListItem: React.FC = ({ onRemove(); }} > - {t('fileManager.delete', 'Delete')} + {t("fileManager.delete", "Delete")} - - { } + {} {canUpload && ( = ({ /> )} {canManageShare && ( - setShowShareManageModal(false)} - file={file} - /> + setShowShareManageModal(false)} file={file} /> )} ); diff --git a/frontend/src/core/components/fileManager/FileSourceButtons.tsx b/frontend/src/core/components/fileManager/FileSourceButtons.tsx index 8695821efd..51076ee954 100644 --- a/frontend/src/core/components/fileManager/FileSourceButtons.tsx +++ b/frontend/src/core/components/fileManager/FileSourceButtons.tsx @@ -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 = ({ - horizontal = false -}) => { +const FileSourceButtons: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ )} {!shouldHideMobileQR && ( )} @@ -178,7 +180,7 @@ const FileSourceButtons: React.FC = ({ if (horizontal) { return ( <> - + {buttons} = ({ return ( <> - - - {t('fileManager.myFiles', 'My Files')} + + + {t("fileManager.myFiles", "My Files")} {buttons} diff --git a/frontend/src/core/components/fileManager/HiddenFileInput.tsx b/frontend/src/core/components/fileManager/HiddenFileInput.tsx index 27482df519..fce23187cd 100644 --- a/frontend/src/core/components/fileManager/HiddenFileInput.tsx +++ b/frontend/src/core/components/fileManager/HiddenFileInput.tsx @@ -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" /> ); diff --git a/frontend/src/core/components/fileManager/MobileLayout.tsx b/frontend/src/core/components/fileManager/MobileLayout.tsx index 0701874852..759fb2c7f9 100644 --- a/frontend/src/core/components/fileManager/MobileLayout.tsx +++ b/frontend/src/core/components/fileManager/MobileLayout.tsx @@ -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 ( - + {/* Section 1: File Sources - Fixed at top */} @@ -42,28 +38,34 @@ const MobileLayout: React.FC = () => { {/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */} - - {activeSource === 'recent' && ( + + {activeSource === "recent" && ( <> - + - + @@ -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, }} /> diff --git a/frontend/src/core/components/fileManager/SearchInput.tsx b/frontend/src/core/components/fileManager/SearchInput.tsx index 2b318604c6..b7dbf9306c 100644 --- a/frontend/src/core/components/fileManager/SearchInput.tsx +++ b/frontend/src/core/components/fileManager/SearchInput.tsx @@ -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 = ({ style }) => { return ( } 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; \ No newline at end of file +export default SearchInput; diff --git a/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx b/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx index 7f6d9f26dd..357b4a2f4a 100644 --- a/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx +++ b/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx @@ -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 = ({ binding, size = 'sm', muted = false }) => { +export const HotkeyDisplay: React.FC = ({ binding, size = "sm", muted = false }) => { const { getDisplayParts } = useHotkeys(); const parts = getDisplayParts(binding); @@ -31,24 +31,26 @@ export const HotkeyDisplay: React.FC = ({ 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 ( {parts.map((part, index) => ( {part} - {index < parts.length - 1 && +} + {index < parts.length - 1 && ( + + + + + )} ))} diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index 521ac5db15..89c04dc670 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -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 ( - - ); + return ; } switch (currentView) { case "fileEditor": - return ( { addFiles(filesToMerge); setCurrentView("viewer"); - } + }, })} /> ); case "viewer": - return ( - +
+ {pageEditorFunctions && ( -
+
+ 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} + />
)}
@@ -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 && ( { + 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 */} {renderMainContent()} diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css b/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css index c507653092..5b3aec2d91 100644 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css @@ -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; diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx index c50ae7b43e..e0a71a3e69 100644 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx @@ -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 ( - {button.icon === 'chevron-left' && } + {button.icon === "chevron-left" && } ); } - const variant = button.variant ?? 'secondary'; + const variant = button.variant ?? "secondary"; const label = resolveButtonLabel(button); return ( diff --git a/frontend/src/core/components/onboarding/Onboarding.tsx b/frontend/src/core/components/onboarding/Onboarding.tsx index d098a801d5..e62ae9cedf 100644 --- a/frontend/src/core/components/onboarding/Onboarding.tsx +++ b/frontend/src/core/components/onboarding/Onboarding.tsx @@ -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(() => { 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 ; - case 'modal-slide': + case "modal-slide": if (!currentSlideDefinition || !currentSlideContent) return null; return ( { - if (slideDefinition.hero.type === 'dual-icon') { + if (slideDefinition.hero.type === "dual-icon") { return (
@@ -56,20 +55,20 @@ export default function OnboardingModalSlide({ return (
- {slideDefinition.hero.type === 'rocket' && ( + {slideDefinition.hero.type === "rocket" && ( )} - {slideDefinition.hero.type === 'shield' && ( + {slideDefinition.hero.type === "shield" && ( )} - {slideDefinition.hero.type === 'lock' && ( + {slideDefinition.hero.type === "lock" && ( )} - {slideDefinition.hero.type === 'analytics' && ( + {slideDefinition.hero.type === "analytics" && ( )} - {slideDefinition.hero.type === 'diamond' && } - {slideDefinition.hero.type === 'logo' && ( + {slideDefinition.hero.type === "diamond" && } + {slideDefinition.hero.type === "logo" && ( Stirling logo )}
@@ -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" }, }} > @@ -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({
-
+
-
+
{slideContent.title}
@@ -146,9 +142,7 @@ export default function OnboardingModalSlide({
- {modalSlideCount > 1 && ( - - )} + {modalSlideCount > 1 && }
); } - diff --git a/frontend/src/core/components/onboarding/OnboardingStepper.tsx b/frontend/src/core/components/onboarding/OnboardingStepper.tsx index ec6767d8ad..23c37643da 100644 --- a/frontend/src/core/components/onboarding/OnboardingStepper.tsx +++ b/frontend/src/core/components/onboarding/OnboardingStepper.tsx @@ -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
{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; - - diff --git a/frontend/src/core/components/onboarding/OnboardingTour.css b/frontend/src/core/components/onboarding/OnboardingTour.css index 54ad69d68d..a1cbd4f3d8 100644 --- a/frontend/src/core/components/onboarding/OnboardingTour.css +++ b/frontend/src/core/components/onboarding/OnboardingTour.css @@ -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; } diff --git a/frontend/src/core/components/onboarding/OnboardingTour.tsx b/frontend/src/core/components/onboarding/OnboardingTour.tsx index 85df0a9fdb..9ddb4e4c04 100644 --- a/frontend/src/core/components/onboarding/OnboardingTour.tsx +++ b/frontend/src/core/components/onboarding/OnboardingTour.tsx @@ -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 ( { @@ -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 ? : } @@ -135,10 +127,10 @@ export default function OnboardingTour({ }} components={{ Close: ({ onClick }) => ( - + ), Content: ({ content }: { content: string }) => ( -
+
), }} > @@ -148,4 +140,3 @@ export default function OnboardingTour({ } export type { AdvanceArgs, CloseArgs }; - diff --git a/frontend/src/core/components/onboarding/adminStepsConfig.ts b/frontend/src/core/components/onboarding/adminStepsConfig.ts index b7a5c0b66c..7946c9fec7 100644 --- a/frontend/src/core/components/onboarding/adminStepsConfig.ts +++ b/frontend/src/core/components/onboarding/adminStepsConfig.ts @@ -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 Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators."), - position: 'right', + content: t( + "adminOnboarding.welcome", + "Welcome to the Admin Tour! 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 Config button to access all system settings and administrative controls."), - position: 'right', + content: t( + "adminOnboarding.configButton", + "Click the Config 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 Settings Panel. Admin settings are organised by category for easy navigation."), - position: 'right', + selector: ".modal-nav", + content: t( + "adminOnboarding.settingsOverview", + "This is the Settings Panel. 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 Teams 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 Teams 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: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints 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: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints 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 external database hookups so you can integrate with your existing infrastructure."), - position: 'right', + content: t( + "adminOnboarding.databaseSection", + "For advanced production environments, we have settings to allow external database hookups 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 Connections 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 Connections 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 Auditing to track system activity and Usage Analytics 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 Auditing to track system activity and Usage Analytics 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 Help 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 Help menu.", + ), + position: "right", padding: 10, action: () => { removeAllGlows(); @@ -141,4 +194,3 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg }, }; } - diff --git a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts index 101fb13eef..c569954cdb 100644 --- a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts +++ b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts @@ -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 = { - '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 }, }; - diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts index 9528694c3e..a4f06e4c72 100644 --- a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts @@ -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); } - diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts index 9e3065614a..08a8049a1b 100644 --- a/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts @@ -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; @@ -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 } diff --git a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts index 54d1d2acf4..4fade3a3fc 100644 --- a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts +++ b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -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): 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): 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(() => - getInitialRuntimeState(defaultState) - ); + const [runtimeState, setRuntimeState] = useState(() => 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(() => ({ - ...serverExperience, - ...runtimeState, - effectiveIsAdmin: serverExperience.effectiveIsAdmin || - (!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'), - }), [serverExperience, runtimeState]); + const conditionContext = useMemo( + () => ({ + ...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) => { 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), []); diff --git a/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx b/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx index c04009ca4c..9521933cb3 100644 --- a/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx @@ -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: (
}} />
-
+
- {analyticsError && ( -
- {analyticsError} -
- )} + {analyticsError &&
{analyticsError}
}
), background: { - gradientStops: ['#0EA5E9', '#6366F1'], + gradientStops: ["#0EA5E9", "#6366F1"], circles: UNIFIED_CIRCLE_CONFIG, }, }; } - diff --git a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx index 289b07b6c4..11bd671c58 100644 --- a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx +++ b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx @@ -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 (
{prevGradientStyle && isTransitioning && ( -
{ setPrevGradient(null); @@ -69,14 +65,14 @@ export default function AnimatedSlideBackground({ /> )}
{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 ( -
- ); + return
; })}
); diff --git a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx index 9cebfbb9cd..0ad0eebc29 100644 --- a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx @@ -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 ( {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.", )} ); @@ -32,11 +32,10 @@ export default function DesktopInstallSlide({ osOptions = [], onDownloadUrlChange, }: DesktopInstallSlideProps): SlideConfig { - return { - key: 'desktop-install', + key: "desktop-install", title: ( - , downloadUrl: osUrl, background: { - gradientStops: ['#2563EB', '#0EA5E9'], + gradientStops: ["#2563EB", "#0EA5E9"], circles: UNIFIED_CIRCLE_CONFIG, }, }; } - diff --git a/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx b/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx index ac42b518b3..b69b1b6b84 100644 --- a/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx +++ b/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx @@ -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 = ({ - osLabel, - osUrl, - osOptions, - onDownloadUrlChange +export const DesktopInstallTitle: React.FC = ({ + osLabel, + osUrl, + osOptions, + onDownloadUrlChange, }) => { const { t } = useTranslation(); const [selectedOsUrl, setSelectedOsUrl] = React.useState(osUrl); @@ -29,37 +29,41 @@ export const DesktopInstallTitle: React.FC = ({ 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
{title}
; + return
{title}
; } return ( -
- {title} +
+ {title} @@ -74,11 +78,9 @@ export const DesktopInstallTitle: React.FC = ({ 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 = ({
); }; - diff --git a/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx b/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx index 46ec6a0c89..df7d5ff544 100644 --- a/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx @@ -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 =
- + - {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.")}
- {t('firstLogin.loggedInAs', 'Logged in as')}: {username} + {t("firstLogin.loggedInAs", "Logged in as")}: {username} {error && ( - } - color="red" - variant="light" - > + } color="red" variant="light"> {error} )} @@ -111,8 +104,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = {/* Only show current password field if not using default credentials */} {!usingDefaultCredentials && ( setCurrentPassword(e.currentTarget.value)} required @@ -123,8 +116,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = )} setNewPassword(e.currentTarget.value)} minLength={8} @@ -135,8 +128,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = /> 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")}
@@ -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: ( ), background: { - gradientStops: ['#059669', '#0891B2'], // Green to teal - security/trust colors + gradientStops: ["#059669", "#0891B2"], // Green to teal - security/trust colors circles: UNIFIED_CIRCLE_CONFIG, }, }; } - diff --git a/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx b/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx index d8def0528a..b565af0f94 100644 --- a/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx @@ -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 - - diff --git a/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx index 3b8d4bfb0c..cb86b2062c 100644 --- a/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx @@ -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 {{freeTierLimit}} 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 {{freeTierLimit}} 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 {{freeTierLimit}} 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 {{freeTierLimit}} people on your server get to use Stirling free of charge."; return ( - }} - defaults={defaultValue} - /> + }} defaults={defaultValue} /> ); }; @@ -49,7 +41,7 @@ const UserOverviewBody: React.FC = () => { return ( {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.", )} @@ -60,8 +52,7 @@ const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; logi isAdmin, freeTierLimit, loginEnabled, -}) => - isAdmin ? : ; +}) => (isAdmin ? : ); 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: , body: , background: { - gradientStops: isAdmin ? ['#4F46E5', '#0EA5E9'] : ['#F97316', '#EF4444'], + gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"], circles: UNIFIED_CIRCLE_CONFIG, }, }; } - diff --git a/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx index 0efb2f591d..f0245e6cd9 100644 --- a/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx @@ -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: (
- - {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.')} + + + {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.", + )} +
setShareRole((value as typeof shareRole) || 'editor')} + onChange={(value) => setShareRole((value as typeof shareRole) || "editor")} comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }} data={[ - { value: 'editor', label: t('storageShare.roleEditor', 'Editor') }, - { value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') }, - { value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') }, + { value: "editor", label: t("storageShare.roleEditor", "Editor") }, + { value: "commenter", label: t("storageShare.roleCommenter", "Commenter") }, + { value: "viewer", label: t("storageShare.roleViewer", "Viewer") }, ]} /> - {shareRole === 'commenter' && ( + {shareRole === "commenter" && ( - {t('storageShare.commenterHint', 'Commenting is coming soon.')} + {t("storageShare.commenterHint", "Commenting is coming soon.")} )} @@ -245,7 +228,7 @@ const BulkShareModal: React.FC = ({ diff --git a/frontend/src/core/components/shared/BulkUploadToServerModal.tsx b/frontend/src/core/components/shared/BulkUploadToServerModal.tsx index 4e97fe74c7..e9fb4650c1 100644 --- a/frontend/src/core/components/shared/BulkUploadToServerModal.tsx +++ b/frontend/src/core/components/shared/BulkUploadToServerModal.tsx @@ -1,15 +1,15 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Modal, Stack, Text, Button, Group, Alert } from '@mantine/core'; -import CloudUploadIcon from '@mui/icons-material/CloudUpload'; -import { useTranslation } from 'react-i18next'; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Modal, Stack, Text, Button, Group, Alert } from "@mantine/core"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import { useTranslation } from "react-i18next"; -import { alert } from '@app/components/toast'; -import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; -import type { StirlingFileStub } from '@app/types/fileContext'; -import { uploadHistoryChains } from '@app/services/serverStorageUpload'; -import { fileStorage } from '@app/services/fileStorage'; -import { useFileActions } from '@app/contexts/FileContext'; -import type { FileId } from '@app/types/file'; +import { alert } from "@app/components/toast"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import { uploadHistoryChains } from "@app/services/serverStorageUpload"; +import { fileStorage } from "@app/services/fileStorage"; +import { useFileActions } from "@app/contexts/FileContext"; +import type { FileId } from "@app/types/file"; interface BulkUploadToServerModalProps { opened: boolean; @@ -18,12 +18,7 @@ interface BulkUploadToServerModalProps { onUploaded?: () => Promise | void; } -const BulkUploadToServerModal: React.FC = ({ - opened, - onClose, - files, - onUploaded, -}) => { +const BulkUploadToServerModal: React.FC = ({ opened, onClose, files, onUploaded }) => { const { t } = useTranslation(); const { actions } = useFileActions(); const [isUploading, setIsUploading] = useState(false); @@ -44,18 +39,11 @@ const BulkUploadToServerModal: React.FC = ({ setErrorMessage(null); try { - const rootIds = Array.from( - new Set(files.map((file) => (file.originalFileId || file.id) as FileId)) - ); - const remoteIds = Array.from( - new Set(files.map((file) => file.remoteStorageId).filter(Boolean) as number[]) - ); + const rootIds = Array.from(new Set(files.map((file) => (file.originalFileId || file.id) as FileId))); + const remoteIds = Array.from(new Set(files.map((file) => file.remoteStorageId).filter(Boolean) as number[])); const existingRemoteId = remoteIds.length === 1 ? remoteIds[0] : undefined; - const { remoteId, updatedAt, chain } = await uploadHistoryChains( - rootIds, - existingRemoteId - ); + const { remoteId, updatedAt, chain } = await uploadHistoryChains(rootIds, existingRemoteId); for (const stub of chain) { actions.updateStirlingFileStub(stub.id, { @@ -73,8 +61,8 @@ const BulkUploadToServerModal: React.FC = ({ } alert({ - alertType: 'success', - title: t('storageUpload.success', 'Uploaded to server'), + alertType: "success", + title: t("storageUpload.success", "Uploaded to server"), expandable: false, durationMs: 3000, }); @@ -83,10 +71,8 @@ const BulkUploadToServerModal: React.FC = ({ } onClose(); } catch (error) { - console.error('Failed to upload files to server:', error); - setErrorMessage( - t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.') - ); + console.error("Failed to upload files to server:", error); + setErrorMessage(t("storageUpload.failure", "Upload failed. Please check your login and storage settings.")); } finally { setIsUploading(false); } @@ -97,48 +83,39 @@ const BulkUploadToServerModal: React.FC = ({ opened={opened} onClose={onClose} centered - title={t('storageUpload.bulkTitle', 'Upload selected files')} + title={t("storageUpload.bulkTitle", "Upload selected files")} zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL} > - - {t( - 'storageUpload.bulkDescription', - 'This uploads the selected files to your server storage.' - )} - + {t("storageUpload.bulkDescription", "This uploads the selected files to your server storage.")} - {t('storageUpload.fileCount', '{{count}} files selected', { + {t("storageUpload.fileCount", "{{count}} files selected", { count: files.length, })} {displayNames.length > 0 && ( - {displayNames.join(', ')} + {displayNames.join(", ")} {fileNames.length > displayNames.length - ? t('storageUpload.more', ' +{{count}} more', { + ? t("storageUpload.more", " +{{count}} more", { count: fileNames.length - displayNames.length, }) - : ''} + : ""} )} {errorMessage && ( - + {errorMessage} )} - diff --git a/frontend/src/core/components/shared/ButtonSelector.test.tsx b/frontend/src/core/components/shared/ButtonSelector.test.tsx index 12a509abd6..d0715a3090 100644 --- a/frontend/src/core/components/shared/ButtonSelector.test.tsx +++ b/frontend/src/core/components/shared/ButtonSelector.test.tsx @@ -1,214 +1,175 @@ -import { describe, expect, test, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { MantineProvider } from '@mantine/core'; -import ButtonSelector from '@app/components/shared/ButtonSelector'; +import { describe, expect, test, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import ButtonSelector from "@app/components/shared/ButtonSelector"; // Wrapper component to provide Mantine context -const TestWrapper = ({ children }: { children: React.ReactNode }) => ( - {children} -); +const TestWrapper = ({ children }: { children: React.ReactNode }) => {children}; -describe('ButtonSelector', () => { +describe("ButtonSelector", () => { const mockOnChange = vi.fn(); beforeEach(() => { vi.clearAllMocks(); }); - test('should render all options as buttons', () => { + test("should render all options as buttons", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ]; render( - - + + , ); - expect(screen.getByText('Test Label')).toBeInTheDocument(); - expect(screen.getByText('Option 1')).toBeInTheDocument(); - expect(screen.getByText('Option 2')).toBeInTheDocument(); + expect(screen.getByText("Test Label")).toBeInTheDocument(); + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 2")).toBeInTheDocument(); }); - test('should highlight selected button with filled variant', () => { + test("should highlight selected button with filled variant", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ]; render( - - + + , ); - const selectedButton = screen.getByRole('button', { name: 'Option 1' }); - const unselectedButton = screen.getByRole('button', { name: 'Option 2' }); + const selectedButton = screen.getByRole("button", { name: "Option 1" }); + const unselectedButton = screen.getByRole("button", { name: "Option 2" }); // Check data-variant attribute for filled/outline - expect(selectedButton).toHaveAttribute('data-variant', 'filled'); - expect(unselectedButton).toHaveAttribute('data-variant', 'outline'); - expect(screen.getByText('Selection Label')).toBeInTheDocument(); + expect(selectedButton).toHaveAttribute("data-variant", "filled"); + expect(unselectedButton).toHaveAttribute("data-variant", "outline"); + expect(screen.getByText("Selection Label")).toBeInTheDocument(); }); - test('should call onChange when button is clicked', () => { + test("should call onChange when button is clicked", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ]; render( - - + + , ); - fireEvent.click(screen.getByRole('button', { name: 'Option 2' })); + fireEvent.click(screen.getByRole("button", { name: "Option 2" })); - expect(mockOnChange).toHaveBeenCalledWith('option2'); + expect(mockOnChange).toHaveBeenCalledWith("option2"); }); - test('should handle undefined value (no selection)', () => { + test("should handle undefined value (no selection)", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ]; render( - - + + , ); // Both buttons should be outlined when no value is selected - const button1 = screen.getByRole('button', { name: 'Option 1' }); - const button2 = screen.getByRole('button', { name: 'Option 2' }); + const button1 = screen.getByRole("button", { name: "Option 1" }); + const button2 = screen.getByRole("button", { name: "Option 2" }); - expect(button1).toHaveAttribute('data-variant', 'outline'); - expect(button2).toHaveAttribute('data-variant', 'outline'); + expect(button1).toHaveAttribute("data-variant", "outline"); + expect(button2).toHaveAttribute("data-variant", "outline"); }); test.each([ { - description: 'disable buttons when disabled prop is true', + description: "disable buttons when disabled prop is true", options: [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ], globalDisabled: true, expectedStates: [true, true], }, { - description: 'disable individual options when option.disabled is true', + description: "disable individual options when option.disabled is true", options: [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2', disabled: true }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2", disabled: true }, ], globalDisabled: false, expectedStates: [false, true], }, - ])('should $description', ({ options, globalDisabled, expectedStates }) => { + ])("should $description", ({ options, globalDisabled, expectedStates }) => { render( - - + + , ); options.forEach((option, index) => { - const button = screen.getByRole('button', { name: option.label }); - expect(button).toHaveProperty('disabled', expectedStates[index]); + const button = screen.getByRole("button", { name: option.label }); + expect(button).toHaveProperty("disabled", expectedStates[index]); }); }); - test('should not call onChange when disabled button is clicked', () => { + test("should not call onChange when disabled button is clicked", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2', disabled: true }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2", disabled: true }, ]; render( - - + + , ); - fireEvent.click(screen.getByRole('button', { name: 'Option 2' })); + fireEvent.click(screen.getByRole("button", { name: "Option 2" })); expect(mockOnChange).not.toHaveBeenCalled(); }); - test('should not apply fullWidth styling when fullWidth is false', () => { + test("should not apply fullWidth styling when fullWidth is false", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ]; render( - - + + , ); - const button = screen.getByRole('button', { name: 'Option 1' }); - expect(button).not.toHaveStyle({ flex: '1' }); - expect(screen.getByText('Layout Label')).toBeInTheDocument(); + const button = screen.getByRole("button", { name: "Option 1" }); + expect(button).not.toHaveStyle({ flex: "1" }); + expect(screen.getByText("Layout Label")).toBeInTheDocument(); }); - test('should not render label element when not provided', () => { + test("should not render label element when not provided", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ]; const { container } = render( - - + + , ); // Should render buttons - expect(screen.getByText('Option 1')).toBeInTheDocument(); - expect(screen.getByText('Option 2')).toBeInTheDocument(); - + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 2")).toBeInTheDocument(); + // Stack should only contain the Group (buttons), no Text element for label const stackElement = container.querySelector('[class*="mantine-Stack-root"]'); expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text diff --git a/frontend/src/core/components/shared/ButtonSelector.tsx b/frontend/src/core/components/shared/ButtonSelector.tsx index 94bd10c6e7..548d75fd81 100644 --- a/frontend/src/core/components/shared/ButtonSelector.tsx +++ b/frontend/src/core/components/shared/ButtonSelector.tsx @@ -5,7 +5,7 @@ export interface ButtonOption { value: T; label: string; disabled?: boolean; - tooltip?: string; // Tooltip shown on hover (useful for explaining why option is disabled) + tooltip?: string; // Tooltip shown on hover (useful for explaining why option is disabled) } interface ButtonSelectorProps { @@ -30,42 +30,42 @@ const ButtonSelector = ({ textClassName, }: ButtonSelectorProps) => { return ( - + {/* Label (if it exists) */} - {label && {label}} + {label && ( + + {label} + + )} {/* Buttons */} - + {options.map((option) => { const isDisabled = disabled || option.disabled; const button = ( ); @@ -73,12 +73,16 @@ const ButtonSelector = ({ if (option.tooltip && isDisabled) { return ( - {button} + {button} ); } - return {button}; + return ( + + {button} + + ); })} diff --git a/frontend/src/core/components/shared/ButtonToggle.tsx b/frontend/src/core/components/shared/ButtonToggle.tsx index f695c8a8bc..2f434e0086 100644 --- a/frontend/src/core/components/shared/ButtonToggle.tsx +++ b/frontend/src/core/components/shared/ButtonToggle.tsx @@ -1,5 +1,5 @@ -import { Button, Stack } from '@mantine/core'; -import React from 'react'; +import { Button, Stack } from "@mantine/core"; +import React from "react"; export interface ButtonToggleOption { value: string; @@ -13,8 +13,8 @@ export interface ButtonToggleProps { value: string; onChange: (value: string) => void; disabled?: boolean; - orientation?: 'vertical' | 'horizontal'; - size?: 'xs' | 'sm' | 'md' | 'lg'; + orientation?: "vertical" | "horizontal"; + size?: "xs" | "sm" | "md" | "lg"; fullWidth?: boolean; } @@ -23,18 +23,18 @@ export const ButtonToggle: React.FC = ({ value, onChange, disabled = false, - orientation = 'vertical', - size = 'md', + orientation = "vertical", + size = "md", fullWidth = true, }) => { - const isVertical = orientation === 'vertical'; + const isVertical = orientation === "vertical"; const buttonStyle: React.CSSProperties = { - justifyContent: 'flex-start', - height: isVertical ? 'auto' : undefined, - minHeight: isVertical ? '50px' : undefined, - padding: isVertical ? '12px 16px' : undefined, - textAlign: 'left', + justifyContent: "flex-start", + height: isVertical ? "auto" : undefined, + minHeight: isVertical ? "50px" : undefined, + padding: isVertical ? "12px 16px" : undefined, + textAlign: "left", }; const renderButton = (option: ButtonToggleOption) => { @@ -44,21 +44,21 @@ export const ButtonToggle: React.FC = ({ return ( ); diff --git a/frontend/src/core/components/shared/DropdownListWithFooter.tsx b/frontend/src/core/components/shared/DropdownListWithFooter.tsx index b5e5a9f5d7..11d457ef99 100644 --- a/frontend/src/core/components/shared/DropdownListWithFooter.tsx +++ b/frontend/src/core/components/shared/DropdownListWithFooter.tsx @@ -1,8 +1,8 @@ -import React, { ReactNode, useState, useMemo } from 'react'; -import { Stack, Text, Popover, Box, Checkbox, Group, TextInput } from '@mantine/core'; -import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; -import SearchIcon from '@mui/icons-material/Search'; -import { Z_INDEX_AUTOMATE_DROPDOWN } from '@app/styles/zIndex'; +import React, { ReactNode, useState, useMemo } from "react"; +import { Stack, Text, Popover, Box, Checkbox, Group, TextInput } from "@mantine/core"; +import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore"; +import SearchIcon from "@mui/icons-material/Search"; +import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; export interface DropdownItem { value: string; @@ -15,30 +15,30 @@ export interface DropdownListWithFooterProps { // Value and onChange - support both single and multi-select value: string | string[]; onChange: (value: string | string[]) => void; - + // Items and display items: DropdownItem[]; placeholder?: string; disabled?: boolean; - + // Labels and headers label?: string; header?: ReactNode; footer?: ReactNode; - + // Behavior multiSelect?: boolean; searchable?: boolean; maxHeight?: number; - + // Styling className?: string; dropdownClassName?: string; - + // Popover props - position?: 'top' | 'bottom' | 'left' | 'right'; + position?: "top" | "bottom" | "left" | "right"; withArrow?: boolean; - width?: 'target' | number; + width?: "target" | number; withinPortal?: boolean; zIndex?: number; } @@ -47,7 +47,7 @@ const DropdownListWithFooter: React.FC = ({ value, onChange, items, - placeholder = 'Select option', + placeholder = "Select option", disabled = false, label, header, @@ -55,34 +55,31 @@ const DropdownListWithFooter: React.FC = ({ multiSelect = false, searchable = false, maxHeight = 300, - className = '', - dropdownClassName = '', - position = 'bottom', + className = "", + dropdownClassName = "", + position = "bottom", withArrow = false, - width = 'target', + width = "target", withinPortal = true, - zIndex = Z_INDEX_AUTOMATE_DROPDOWN + zIndex = Z_INDEX_AUTOMATE_DROPDOWN, }) => { - - const [searchTerm, setSearchTerm] = useState(''); - + const [searchTerm, setSearchTerm] = useState(""); + const isMultiValue = Array.isArray(value); - const selectedValues = isMultiValue ? value : (value ? [value] : []); + const selectedValues = isMultiValue ? value : value ? [value] : []; // Filter items based on search term const filteredItems = useMemo(() => { if (!searchable || !searchTerm.trim()) { return items; } - return items.filter(item => - item.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); + return items.filter((item) => item.name.toLowerCase().includes(searchTerm.toLowerCase())); }, [items, searchTerm, searchable]); const handleItemClick = (itemValue: string) => { if (multiSelect) { const newSelection = selectedValues.includes(itemValue) - ? selectedValues.filter(v => v !== itemValue) + ? selectedValues.filter((v) => v !== itemValue) : [...selectedValues, itemValue]; onChange(newSelection); } else { @@ -94,7 +91,7 @@ const DropdownListWithFooter: React.FC = ({ if (selectedValues.length === 0) { return placeholder; } else if (selectedValues.length === 1) { - const selectedItem = items.find(item => item.value === selectedValues[0]); + const selectedItem = items.find((item) => item.value === selectedValues[0]); return selectedItem?.name || selectedValues[0]; } else { return `${selectedValues.length} selected`; @@ -112,125 +109,130 @@ const DropdownListWithFooter: React.FC = ({ {label} )} - - searchable && setSearchTerm('')} + onClose={() => searchable && setSearchTerm("")} withinPortal={withinPortal} zIndex={zIndex} > {getDisplayText()} - + - + {header && ( - + {header} )} - + {searchable && ( - + } + leftSection={} size="sm" - style={{ width: '100%' }} + style={{ width: "100%" }} /> )} - - + + {filteredItems.length === 0 ? ( - + - {searchable && searchTerm ? 'No results found' : 'No items available'} + {searchable && searchTerm ? "No results found" : "No items available"} ) : ( filteredItems.map((item) => ( - !item.disabled && handleItemClick(item.value)} - style={{ - padding: '8px 12px', - cursor: item.disabled ? 'not-allowed' : 'pointer', - borderRadius: 'var(--mantine-radius-sm)', - opacity: item.disabled ? 0.5 : 1, - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between' - }} - onMouseEnter={(e) => { - if (!item.disabled) { - e.currentTarget.style.backgroundColor = 'light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5))'; - } - }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = 'transparent'; - }} - > - - {item.leftIcon && ( - - {item.leftIcon} - + !item.disabled && handleItemClick(item.value)} + style={{ + padding: "8px 12px", + cursor: item.disabled ? "not-allowed" : "pointer", + borderRadius: "var(--mantine-radius-sm)", + opacity: item.disabled ? 0.5 : 1, + display: "flex", + alignItems: "center", + justifyContent: "space-between", + }} + onMouseEnter={(e) => { + if (!item.disabled) { + e.currentTarget.style.backgroundColor = + "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5))"; + } + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = "transparent"; + }} + > + + {item.leftIcon && {item.leftIcon}} + {item.name} + + + {multiSelect && ( + {}} // Handled by parent onClick + size="sm" + disabled={item.disabled} + /> )} - {item.name} - - - {multiSelect && ( - {}} // Handled by parent onClick - size="sm" - disabled={item.disabled} - /> - )} - + )) )} - + {footer && ( - + {footer} )} @@ -241,4 +243,4 @@ const DropdownListWithFooter: React.FC = ({ ); }; -export default DropdownListWithFooter; \ No newline at end of file +export default DropdownListWithFooter; diff --git a/frontend/src/core/components/shared/EditableSecretField.tsx b/frontend/src/core/components/shared/EditableSecretField.tsx index dfd3da6458..927e46a73c 100644 --- a/frontend/src/core/components/shared/EditableSecretField.tsx +++ b/frontend/src/core/components/shared/EditableSecretField.tsx @@ -1,7 +1,7 @@ -import { useState, useRef, useEffect } from 'react'; -import { PasswordInput, Group, ActionIcon, Tooltip, TextInput } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import LocalIcon from '@app/components/shared/LocalIcon'; +import { useState, useRef, useEffect } from "react"; +import { PasswordInput, Group, ActionIcon, Tooltip, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; interface EditableSecretFieldProps { label?: string; @@ -26,16 +26,16 @@ export default function EditableSecretField({ description, value, onChange, - placeholder = 'Enter value', + placeholder = "Enter value", disabled = false, error, }: EditableSecretFieldProps) { const { t } = useTranslation(); const [isEditing, setIsEditing] = useState(false); - const [tempValue, setTempValue] = useState(''); + const [tempValue, setTempValue] = useState(""); const inputRef = useRef(null); - const isMasked = value === '********'; + const isMasked = value === "********"; useEffect(() => { if (isEditing && inputRef.current) { @@ -44,45 +44,34 @@ export default function EditableSecretField({ }, [isEditing]); const handleEdit = () => { - setTempValue(''); + setTempValue(""); setIsEditing(true); }; const handleCancel = () => { - setTempValue(''); + setTempValue(""); setIsEditing(false); }; const handleSave = () => { - if (tempValue.trim() !== '') { + if (tempValue.trim() !== "") { onChange(tempValue); } - setTempValue(''); + setTempValue(""); setIsEditing(false); }; return (
- {label && } - {description &&

{description}

} + {label && } + {description &&

{description}

} {isMasked && !isEditing ? ( // Masked value from backend: show display + Edit button - - - + + + @@ -99,7 +88,7 @@ export default function EditableSecretField({ autoComplete="new-password" onBlur={handleSave} onKeyDown={(e) => { - if (e.key === 'Escape') handleCancel(); + if (e.key === "Escape") handleCancel(); }} /> ) : ( diff --git a/frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx b/frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx index 5ba2dbf053..765f90704b 100644 --- a/frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx +++ b/frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx @@ -1,7 +1,7 @@ -import { Modal, Stack, Text, Button, PasswordInput, Group } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { type KeyboardEventHandler } from 'react'; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; +import { Modal, Stack, Text, Button, PasswordInput, Group } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { type KeyboardEventHandler } from "react"; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; interface EncryptedPdfUnlockModalProps { opened: boolean; @@ -27,7 +27,7 @@ const EncryptedPdfUnlockModal = ({ const { t } = useTranslation(); const handleKeyDown: KeyboardEventHandler = (event) => { - if (event.key === 'Enter' && !isProcessing && password.trim().length > 0) { + if (event.key === "Enter" && !isProcessing && password.trim().length > 0) { onUnlock(); } }; @@ -36,7 +36,7 @@ const EncryptedPdfUnlockModal = ({ - {fileName} + + {fileName} + {t( - 'encryptedPdfUnlock.description', - 'This PDF is password protected. Enter the password so you can continue working with it.' + "encryptedPdfUnlock.description", + "This PDF is password protected. Enter the password so you can continue working with it.", )} onPasswordChange(event.currentTarget.value)} onKeyDown={handleKeyDown} @@ -71,10 +73,10 @@ const EncryptedPdfUnlockModal = ({ diff --git a/frontend/src/core/components/shared/ErrorBoundary.tsx b/frontend/src/core/components/shared/ErrorBoundary.tsx index 0bab94f0a2..5b075c6a93 100644 --- a/frontend/src/core/components/shared/ErrorBoundary.tsx +++ b/frontend/src/core/components/shared/ErrorBoundary.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Text, Button, Stack } from '@mantine/core'; +import React from "react"; +import { Text, Button, Stack } from "@mantine/core"; interface ErrorBoundaryState { hasError: boolean; @@ -8,7 +8,7 @@ interface ErrorBoundaryState { interface ErrorBoundaryProps { children: React.ReactNode; - fallback?: React.ComponentType<{error?: Error; retry: () => void}>; + fallback?: React.ComponentType<{ error?: Error; retry: () => void }>; } export default class ErrorBoundary extends React.Component { @@ -23,22 +23,22 @@ export default class ErrorBoundary extends React.Component { @@ -72,26 +72,36 @@ export default class ErrorBoundary extends React.Component - Something went wrong - {process.env.NODE_ENV === 'development' && this.state.error && ( + + + Something went wrong + + {process.env.NODE_ENV === "development" && this.state.error && ( <> - + {this.state.error.message} {this.state.error.stack && ( -
- - Show stack trace +
+ + + Show stack trace + -
+                  
                     {this.state.error.stack}
                   
diff --git a/frontend/src/core/components/shared/FileCard.tsx b/frontend/src/core/components/shared/FileCard.tsx index dda4791753..569345f9d5 100644 --- a/frontend/src/core/components/shared/FileCard.tsx +++ b/frontend/src/core/components/shared/FileCard.tsx @@ -22,7 +22,17 @@ interface FileCardProps { isSupported?: boolean; // Whether the file format is supported by the current tool } -const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => { +const FileCard = ({ + file, + fileStub, + onRemove, + onDoubleClick, + onView, + onEdit, + isSelected, + onSelect, + isSupported = true, +}: FileCardProps) => { const { t } = useTranslation(); // Use record thumbnail if available, otherwise fall back to IndexedDB lookup const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileStub); @@ -30,7 +40,7 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS const [isHovered, setIsHovered] = useState(false); // Show loading state during hydration: PDF file without thumbnail yet - const isPdf = file.type === 'application/pdf'; + const isPdf = file.type === "application/pdf"; const isHydrating = isPdf && !thumb && !isGenerating; return ( @@ -44,11 +54,11 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS minWidth: 180, maxWidth: 260, cursor: onDoubleClick && isSupported ? "pointer" : undefined, - position: 'relative', - border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined, - backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined, + position: "relative", + border: isSelected ? "2px solid var(--mantine-color-blue-6)" : undefined, + backgroundColor: isSelected ? "var(--mantine-color-blue-0)" : undefined, opacity: isSupported ? 1 : 0.5, - filter: isSupported ? 'none' : 'grayscale(50%)' + filter: isSupported ? "none" : "grayscale(50%)", }} onDoubleClick={onDoubleClick} onMouseEnter={() => setIsHovered(true)} @@ -69,22 +79,22 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS justifyContent: "center", margin: "0 auto", background: "#fafbfc", - position: 'relative' + position: "relative", }} > {/* Hover action buttons */} {isHovered && (onView || onEdit) && (
e.stopPropagation()} > @@ -121,26 +131,23 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
)} {thumb ? ( - PDF thumbnail - ) : (isGenerating || isHydrating) ? ( + PDF thumbnail + ) : isGenerating || isHydrating ? ( - Loading... + + Loading... + ) : ( -
+
100 * 1024 * 1024 ? "orange" : "red"} @@ -151,7 +158,9 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS {file.size > 100 * 1024 * 1024 && ( - Large File + + Large File + )}
)} @@ -169,12 +178,7 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS {getFileDate(file)} {fileStub?.id && ( - } - > + }> DB )} diff --git a/frontend/src/core/components/shared/FileDropdownMenu.tsx b/frontend/src/core/components/shared/FileDropdownMenu.tsx index fcb1a6a266..cd35d43aa1 100644 --- a/frontend/src/core/components/shared/FileDropdownMenu.tsx +++ b/frontend/src/core/components/shared/FileDropdownMenu.tsx @@ -1,12 +1,12 @@ -import React from 'react'; -import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from '@mantine/core'; -import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import CloseIcon from '@mui/icons-material/Close'; -import FitText from '@app/components/shared/FitText'; -import { PrivateContent } from '@app/components/shared/PrivateContent'; -import { FileId } from '@app/types/file'; -import { truncateCenter } from '@app/utils/textUtils'; +import React from "react"; +import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from "@mantine/core"; +import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import CloseIcon from "@mui/icons-material/Close"; +import FitText from "@app/components/shared/FitText"; +import { PrivateContent } from "@app/components/shared/PrivateContent"; +import { FileId } from "@app/types/file"; +import { truncateCenter } from "@app/utils/textUtils"; interface FileDropdownMenuProps { displayName: string; @@ -31,7 +31,7 @@ export const FileDropdownMenu: React.FC = ({ return ( -
+
{switchingTo === "viewer" ? ( ) : ( @@ -41,22 +41,24 @@ export const FileDropdownMenu: React.FC = ({
- + {activeFiles.map((file, index) => { - const itemName = file?.name || 'Untitled'; + const itemName = file?.name || "Untitled"; const isActive = index === currentFileIndex; return ( = ({ onFileSelect?.(index); }} className="viewer-file-tab" - {...(isActive && { 'data-active': true })} + {...(isActive && { "data-active": true })} style={{ - justifyContent: 'flex-start', + justifyContent: "flex-start", }} > - -
+ +
diff --git a/frontend/src/core/components/shared/FileGrid.tsx b/frontend/src/core/components/shared/FileGrid.tsx index 5bd31008db..c9ec2fe1b5 100644 --- a/frontend/src/core/components/shared/FileGrid.tsx +++ b/frontend/src/core/components/shared/FileGrid.tsx @@ -24,7 +24,7 @@ interface FileGridProps { isFileSupported?: (fileName: string) => boolean; // Function to check if file is supported } -type SortOption = 'date' | 'name' | 'size'; +type SortOption = "date" | "name" | "size"; const FileGrid = ({ files, @@ -40,25 +40,23 @@ const FileGrid = ({ onShowAll, showingAll = false, onDeleteAll, - isFileSupported + isFileSupported, }: FileGridProps) => { const { t } = useTranslation(); const [searchTerm, setSearchTerm] = useState(""); - const [sortBy, setSortBy] = useState('date'); + const [sortBy, setSortBy] = useState("date"); // Filter files based on search term - const filteredFiles = files.filter(item => - item.file.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); + const filteredFiles = files.filter((item) => item.file.name.toLowerCase().includes(searchTerm.toLowerCase())); // Sort files const sortedFiles = [...filteredFiles].sort((a, b) => { switch (sortBy) { - case 'date': + case "date": return (b.file.lastModified || 0) - (a.file.lastModified || 0); - case 'name': + case "name": return a.file.name.localeCompare(b.file.name); - case 'size': + case "size": return (b.file.size || 0) - (a.file.size || 0); default: return 0; @@ -66,14 +64,12 @@ const FileGrid = ({ }); // Apply max display limit if specified - const displayFiles = maxDisplay && !showingAll - ? sortedFiles.slice(0, maxDisplay) - : sortedFiles; + const displayFiles = maxDisplay && !showingAll ? sortedFiles.slice(0, maxDisplay) : sortedFiles; const hasMoreFiles = maxDisplay && !showingAll && sortedFiles.length > maxDisplay; return ( - + {/* Search and Sort Controls */} {(showSearch || showSort || onDeleteAll) && ( @@ -91,9 +87,9 @@ const FileGrid = ({ {showSort && ( + ); } diff --git a/frontend/src/core/components/shared/LandingDocumentStack.tsx b/frontend/src/core/components/shared/LandingDocumentStack.tsx index 3fffd48602..b0a37a0f67 100644 --- a/frontend/src/core/components/shared/LandingDocumentStack.tsx +++ b/frontend/src/core/components/shared/LandingDocumentStack.tsx @@ -19,9 +19,9 @@ export function LandingDocumentStack() {
-
-
-
+
+
+
diff --git a/frontend/src/core/components/shared/LandingPage.tsx b/frontend/src/core/components/shared/LandingPage.tsx index 1d9b878f46..46675255f3 100644 --- a/frontend/src/core/components/shared/LandingPage.tsx +++ b/frontend/src/core/components/shared/LandingPage.tsx @@ -1,14 +1,14 @@ -import React, { useState } from 'react'; -import { Container } from '@mantine/core'; -import { Dropzone } from '@mantine/dropzone'; -import { useTranslation } from 'react-i18next'; -import { useFileHandler } from '@app/hooks/useFileHandler'; -import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; -import MobileUploadModal from '@app/components/shared/MobileUploadModal'; -import { openFilesFromDisk } from '@app/services/openFilesFromDisk'; -import { LandingDocumentStack } from '@app/components/shared/LandingDocumentStack'; -import { LandingActions } from '@app/components/shared/LandingActions'; -import '@app/components/shared/LandingPage.css'; +import React, { useState } from "react"; +import { Container } from "@mantine/core"; +import { Dropzone } from "@mantine/dropzone"; +import { useTranslation } from "react-i18next"; +import { useFileHandler } from "@app/hooks/useFileHandler"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; +import MobileUploadModal from "@app/components/shared/MobileUploadModal"; +import { openFilesFromDisk } from "@app/services/openFilesFromDisk"; +import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack"; +import { LandingActions } from "@app/components/shared/LandingActions"; +import "@app/components/shared/LandingPage.css"; const LandingPage = () => { const { t } = useTranslation(); @@ -36,7 +36,7 @@ const LandingPage = () => { if (files.length > 0) { await addFiles(files); } - event.target.value = ''; + event.target.value = ""; }; const handleFilesReceivedFromMobile = async (files: File[]) => { @@ -46,7 +46,7 @@ const LandingPage = () => { }; return ( - + { className="flex min-h-0 flex-1 cursor-default flex-col items-center justify-center border-none bg-transparent px-4 py-8 shadow-none outline-none" styles={{ root: { - border: 'none !important', - backgroundColor: 'transparent', - overflow: 'visible', - '&[data-accept]': { outline: '2px dashed var(--accent-interactive)', outlineOffset: 4 }, - '&[data-reject]': { outline: '2px dashed var(--mantine-color-red-6)', outlineOffset: 4 }, + border: "none !important", + backgroundColor: "transparent", + overflow: "visible", + "&[data-accept]": { outline: "2px dashed var(--accent-interactive)", outlineOffset: 4 }, + "&[data-reject]": { outline: "2px dashed var(--mantine-color-red-6)", outlineOffset: 4 }, }, - inner: { overflow: 'visible', display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%' }, + inner: { overflow: "visible", display: "flex", flexDirection: "column", alignItems: "center", width: "100%" }, }} > -

{t('landing.heroTitle', 'Stirling PDF')}

-

{t('landing.heroSubtitle', 'Drop in or add an existing PDF to get started.')}

+

{t("landing.heroTitle", "Stirling PDF")}

+

{t("landing.heroSubtitle", "Drop in or add an existing PDF to get started.")}

['position']; + position?: React.ComponentProps["position"]; offset?: number; compact?: boolean; // icon-only trigger tooltip?: string; // tooltip text for compact mode @@ -48,12 +48,12 @@ const LanguageItem: React.FC = ({ rippleEffect, pendingLanguage, compact, - disabled = false + disabled = false, }) => { const { t } = useTranslation(); const labelText = option.label; - const comingSoonText = t('comingSoon', 'Coming soon'); + const comingSoonText = t("comingSoon", "Coming soon"); const label = disabled ? ( @@ -68,7 +68,7 @@ const LanguageItem: React.FC = ({ className={styles.languageItem} style={{ opacity: animationTriggered ? 1 : 0, - transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)', + transform: animationTriggered ? "translateY(0px)" : "translateY(8px)", transition: `opacity 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s, transform 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s`, }} > @@ -81,40 +81,42 @@ const LanguageItem: React.FC = ({ disabled={disabled} styles={{ root: { - borderRadius: '4px', - minHeight: '32px', - padding: '4px 8px', - justifyContent: 'flex-start', - position: 'relative', - overflow: 'hidden', + borderRadius: "4px", + minHeight: "32px", + padding: "4px 8px", + justifyContent: "flex-start", + position: "relative", + overflow: "hidden", backgroundColor: isSelected - ? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))' - : 'transparent', + ? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))" + : "transparent", color: disabled - ? 'light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3))' + ? "light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3))" : isSelected - ? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))' - : 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))', - transition: 'all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)', - cursor: disabled ? 'not-allowed' : 'pointer', - '&:hover': !disabled ? { - backgroundColor: isSelected - ? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))' - : 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', - transform: 'translateY(-1px)', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', - } : {} + ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" + : "light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))", + transition: "all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)", + cursor: disabled ? "not-allowed" : "pointer", + "&:hover": !disabled + ? { + backgroundColor: isSelected + ? "light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))" + : "light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))", + transform: "translateY(-1px)", + boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)", + } + : {}, }, label: { - fontSize: '13px', + fontSize: "13px", fontWeight: isSelected ? 600 : 400, - textAlign: 'left', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - position: 'relative', + textAlign: "left", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + position: "relative", zIndex: 2, - } + }, }} > {label} @@ -122,16 +124,16 @@ const LanguageItem: React.FC = ({
@@ -155,10 +157,10 @@ const RippleStyles: React.FC = () => ( // Main component const LanguageSelector: React.FC = ({ - position = 'bottom-start', + position = "bottom-start", offset = 8, compact = false, - tooltip + tooltip, }) => { const { i18n, ready } = useTranslation(); const [opened, setOpened] = useState(false); @@ -183,8 +185,7 @@ const LanguageSelector: React.FC = ({ // Get the filtered list of supported languages from i18n // This respects server config (ui.languages) applied by AppConfigLoader - const allowedLanguages = (i18n.options.supportedLngs as string[] || []) - .filter(lang => lang !== 'cimode'); // Exclude i18next debug language + const allowedLanguages = ((i18n.options.supportedLngs as string[]) || []).filter((lang) => lang !== "cimode"); // Exclude i18next debug language const languageOptions: LanguageOption[] = Object.entries(supportedLanguages) .filter(([code]) => allowedLanguages.length === 0 || allowedLanguages.includes(code)) @@ -196,13 +197,9 @@ const LanguageSelector: React.FC = ({ // Calculate dropdown width and grid columns based on number of languages // 2-4: 300px/2 cols, 5-9: 400px/3 cols, 10+: 600px/4 cols - const dropdownWidth = languageOptions.length <= 4 ? 300 - : languageOptions.length <= 9 ? 400 - : 600; + const dropdownWidth = languageOptions.length <= 4 ? 300 : languageOptions.length <= 9 ? 400 : 600; - const gridColumns = languageOptions.length <= 4 ? 2 - : languageOptions.length <= 9 ? 3 - : 4; + const gridColumns = languageOptions.length <= 4 ? 2 : languageOptions.length <= 9 ? 3 : 4; const handleLanguageChange = (value: string, event: React.MouseEvent) => { // Create ripple effect at click position (only for button mode) @@ -229,16 +226,15 @@ const LanguageSelector: React.FC = ({ setTimeout(() => setRippleEffect(null), 50); // Force a full reload so RTL/LTR layout and tooltips re-evaluate correctly - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { window.location.reload(); } }, 150); }, 100); }; - const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] || - supportedLanguages['en-GB'] || - 'English'; // Fallback if supportedLanguages lookup fails + const currentLanguage = + supportedLanguages[i18n.language as keyof typeof supportedLanguages] || supportedLanguages["en-GB"] || "English"; // Fallback if supportedLanguages lookup fails // Hide the language selector if there's only one language option // (no point showing a selector when there's nothing to select) @@ -258,9 +254,9 @@ const LanguageSelector: React.FC = ({ zIndex={Z_INDEX_CONFIG_MODAL} withinPortal transitionProps={{ - transition: 'scale-y', + transition: "scale-y", duration: 120, - timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' + timingFunction: "cubic-bezier(0.25, 0.46, 0.45, 0.94)", }} > @@ -272,11 +268,11 @@ const LanguageSelector: React.FC = ({ title={!opened && tooltip ? tooltip : undefined} styles={{ root: { - color: 'var(--right-rail-icon)', - '&:hover': { - backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', - } - } + color: "var(--right-rail-icon)", + "&:hover": { + backgroundColor: "light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))", + }, + }, }} > @@ -288,50 +284,45 @@ const LanguageSelector: React.FC = ({ leftSection={} styles={{ root: { - border: 'none', - color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))', - transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)', - '&:hover': { - backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', - } + border: "none", + color: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))", + transition: "background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)", + "&:hover": { + backgroundColor: "light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))", + }, }, - label: { fontSize: '12px', fontWeight: 500 } + label: { fontSize: "12px", fontWeight: 500 }, }} > - - {currentLanguage} - + {currentLanguage} )} -
- {languageOptions.map((option, index) => ( - handleLanguageChange(option.value, event)} - rippleEffect={rippleEffect} - pendingLanguage={pendingLanguage} - compact={compact} - disabled={false} - /> - ))} +
+ {languageOptions.map((option, index) => ( + handleLanguageChange(option.value, event)} + rippleEffect={rippleEffect} + pendingLanguage={pendingLanguage} + compact={compact} + disabled={false} + /> + ))}
diff --git a/frontend/src/core/components/shared/LocalIcon.tsx b/frontend/src/core/components/shared/LocalIcon.tsx index ff7ca493af..5b12dc1fd9 100644 --- a/frontend/src/core/components/shared/LocalIcon.tsx +++ b/frontend/src/core/components/shared/LocalIcon.tsx @@ -1,6 +1,6 @@ -import React from 'react'; -import { addCollection, Icon } from '@iconify/react'; -import iconSet from '../../../assets/material-symbols-icons.json'; // eslint-disable-line no-restricted-imports -- Outside app paths +import React from "react"; +import { addCollection, Icon } from "@iconify/react"; +import iconSet from "../../../assets/material-symbols-icons.json"; // eslint-disable-line no-restricted-imports -- Outside app paths // Load icons synchronously at import time - guaranteed to be ready on first render let iconsLoaded = false; @@ -13,7 +13,7 @@ try { console.info(`āœ… Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`); } } catch { - console.info('ā„¹ļø Local icons not available - using CDN fallback'); + console.info("ā„¹ļø Local icons not available - using CDN fallback"); } interface LocalIconProps { @@ -30,17 +30,15 @@ interface LocalIconProps { */ export const LocalIcon: React.FC = ({ icon, width, height, style, ...props }) => { // Convert our icon naming convention to the local collection format - const iconName = icon.startsWith('material-symbols:') - ? icon - : `material-symbols:${icon}`; + const iconName = icon.startsWith("material-symbols:") ? icon : `material-symbols:${icon}`; // Development logging (only in dev mode) - if (process.env.NODE_ENV === 'development') { + if (process.env.NODE_ENV === "development") { const logKey = `icon-${iconName}`; if (!sessionStorage.getItem(logKey)) { - const source = iconsLoaded ? 'local' : 'CDN'; + const source = iconsLoaded ? "local" : "CDN"; console.debug(`šŸŽÆ Icon: ${iconName} (${source})`); - sessionStorage.setItem(logKey, 'logged'); + sessionStorage.setItem(logKey, "logged"); } } @@ -48,10 +46,10 @@ export const LocalIcon: React.FC = ({ icon, width, height, style // Use width if provided, otherwise fall back to height const size = width || height; - if (size && typeof size === 'string') { + if (size && typeof size === "string") { // If it's a CSS unit string (like '1.5rem'), use it as fontSize iconStyle.fontSize = size; - } else if (typeof size === 'number') { + } else if (typeof size === "number") { // If it's a number, treat it as pixels iconStyle.fontSize = `${size}px`; } diff --git a/frontend/src/core/components/shared/MobileUploadModal.tsx b/frontend/src/core/components/shared/MobileUploadModal.tsx index 275b2cb0d9..4358003cac 100644 --- a/frontend/src/core/components/shared/MobileUploadModal.tsx +++ b/frontend/src/core/components/shared/MobileUploadModal.tsx @@ -1,16 +1,16 @@ -import { useEffect, useCallback, useState, useRef } from 'react'; -import { Modal, Stack, Text, Badge, Box, Alert } from '@mantine/core'; -import { QRCodeSVG } from 'qrcode.react'; -import { useTranslation } from 'react-i18next'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import InfoRoundedIcon from '@mui/icons-material/InfoRounded'; -import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded'; -import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; -import WarningRoundedIcon from '@mui/icons-material/WarningRounded'; -import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; -import { withBasePath } from '@app/constants/app'; -import { convertImageToPdf, isImageFile } from '@app/utils/imageToPdfUtils'; -import apiClient from '@app/services/apiClient'; +import { useEffect, useCallback, useState, useRef } from "react"; +import { Modal, Stack, Text, Badge, Box, Alert } from "@mantine/core"; +import { QRCodeSVG } from "qrcode.react"; +import { useTranslation } from "react-i18next"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; +import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded"; +import CheckRoundedIcon from "@mui/icons-material/CheckRounded"; +import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import { withBasePath } from "@app/constants/app"; +import { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils"; +import apiClient from "@app/services/apiClient"; interface MobileUploadModalProps { opened: boolean; @@ -21,9 +21,9 @@ interface MobileUploadModalProps { // Generate a cryptographically secure UUID v4-like session ID function generateSessionId(): string { // Use Web Crypto API for cryptographically secure random values - const cryptoObj = typeof crypto !== 'undefined' ? crypto : (window as any).crypto; + const cryptoObj = typeof crypto !== "undefined" ? crypto : (window as any).crypto; - if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') { + if (cryptoObj && typeof cryptoObj.getRandomValues === "function") { const bytes = new Uint8Array(16); cryptoObj.getRandomValues(bytes); @@ -32,19 +32,19 @@ function generateSessionId(): string { bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 // Convert bytes to hex string in UUID format - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')); + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")); return [ - hex.slice(0, 4).join(''), - hex.slice(4, 6).join(''), - hex.slice(6, 8).join(''), - hex.slice(8, 10).join(''), - hex.slice(10, 16).join(''), - ].join('-'); + hex.slice(0, 4).join(""), + hex.slice(4, 6).join(""), + hex.slice(6, 8).join(""), + hex.slice(8, 10).join(""), + hex.slice(10, 16).join(""), + ].join("-"); } // If Web Crypto is not available, fail fast rather than using insecure randomness - console.error('Web Crypto API not available. Cannot generate secure session ID.'); - throw new Error('Web Crypto API not available. Cannot generate secure session ID.'); + console.error("Web Crypto API not available. Cannot generate secure session ID."); + throw new Error("Web Crypto API not available. Cannot generate secure session ID."); } interface SessionInfo { @@ -76,30 +76,37 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: // Use configured frontendUrl if set, otherwise use current origin // Combine with base path and mobile-scanner route - const baseUrl = localStorage.getItem('server_url') || ''; + const baseUrl = localStorage.getItem("server_url") || ""; const frontendUrl = baseUrl || config?.frontendUrl || window.location.origin; - const mobileUrl = `${frontendUrl}${withBasePath('/mobile-scanner')}?session=${sessionId}`; + const mobileUrl = `${frontendUrl}${withBasePath("/mobile-scanner")}?session=${sessionId}`; // Create session on backend - const createSession = useCallback(async (newSessionId: string) => { - try { - const response = await apiClient.post(`/api/v1/mobile-scanner/create-session/${newSessionId}`, undefined, { - responseType: 'json', - }); + const createSession = useCallback( + async (newSessionId: string) => { + try { + const response = await apiClient.post( + `/api/v1/mobile-scanner/create-session/${newSessionId}`, + undefined, + { + responseType: "json", + }, + ); - if (!response.status || response.status !== 200) { - throw new Error('Failed to create session'); + if (!response.status || response.status !== 200) { + throw new Error("Failed to create session"); + } + + const data = response.data; + setSessionInfo(data); + setError(null); + console.log("[MobileUploadModal] Session created:", data); + } catch (err) { + console.error("[MobileUploadModal] Failed to create session:", err); + setError(t("mobileUpload.sessionCreateError", "Failed to create session")); } - - const data = response.data; - setSessionInfo(data); - setError(null); - console.log('[MobileUploadModal] Session created:', data); - } catch (err) { - console.error('[MobileUploadModal] Failed to create session:', err); - setError(t('mobileUpload.sessionCreateError', 'Failed to create session')); - } - }, [t]); + }, + [t], + ); // Regenerate session (when expired or warned) const regenerateSession = useCallback(() => { @@ -117,7 +124,7 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: try { const response = await apiClient.get(`/api/v1/mobile-scanner/files/${sessionId}`); if (!response.status || response.status !== 200) { - throw new Error('Failed to check for files'); + throw new Error("Failed to check for files"); } const data = response.data; @@ -130,28 +137,29 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: for (const fileMetadata of newFiles) { try { const downloadResponse = await apiClient.get( - `/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`, { - responseType: 'blob', - } + `/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`, + { + responseType: "blob", + }, ); if (downloadResponse.status === 200) { const blob = downloadResponse.data; let file = new File([blob], fileMetadata.filename, { - type: fileMetadata.contentType || 'image/jpeg' + type: fileMetadata.contentType || "image/jpeg", }); // Convert images to PDF if enabled if (isImageFile(file) && config?.mobileScannerConvertToPdf !== false) { try { file = await convertImageToPdf(file, { - imageResolution: config?.mobileScannerImageResolution as 'full' | 'reduced' | undefined, - pageFormat: config?.mobileScannerPageFormat as 'keep' | 'A4' | 'letter' | undefined, + imageResolution: config?.mobileScannerImageResolution as "full" | "reduced" | undefined, + pageFormat: config?.mobileScannerPageFormat as "keep" | "A4" | "letter" | undefined, stretchToFit: config?.mobileScannerStretchToFit, }); - console.log('[MobileUploadModal] Converted image to PDF:', file.name); + console.log("[MobileUploadModal] Converted image to PDF:", file.name); } catch (convertError) { - console.warn('[MobileUploadModal] Failed to convert image to PDF, using original file:', convertError); + console.warn("[MobileUploadModal] Failed to convert image to PDF, using original file:", convertError); // Continue with original image file if conversion fails } } @@ -161,7 +169,7 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: onFilesReceived([file]); } } catch (err) { - console.error('[MobileUploadModal] Failed to download file:', fileMetadata.filename, err); + console.error("[MobileUploadModal] Failed to download file:", fileMetadata.filename, err); } } @@ -169,14 +177,14 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: // This ensures files are only on server for ~1 second try { await apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`); - console.log('[MobileUploadModal] Session cleaned up after file download'); + console.log("[MobileUploadModal] Session cleaned up after file download"); } catch (cleanupErr) { - console.warn('[MobileUploadModal] Failed to cleanup session after download:', cleanupErr); + console.warn("[MobileUploadModal] Failed to cleanup session after download:", cleanupErr); } } } catch (err) { - console.error('[MobileUploadModal] Error polling for files:', err); - setError(t('mobileUpload.pollingError', 'Error checking for files')); + console.error("[MobileUploadModal] Error polling for files:", err); + setError(t("mobileUpload.pollingError", "Error checking for files")); } }, [opened, sessionId, onFilesReceived, t]); @@ -201,9 +209,10 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: processedFiles.current.clear(); return () => { - console.log('Cleaning up session on unmount/close:', sessionId); - apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`) - .catch(err => console.warn('[MobileUploadModal] Cleanup failed:', err)); + console.log("Cleaning up session on unmount/close:", sessionId); + apiClient + .delete(`/api/v1/mobile-scanner/session/${sessionId}`) + .catch((err) => console.warn("[MobileUploadModal] Cleanup failed:", err)); }; }, [opened, sessionId, createSession]); @@ -267,7 +276,7 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: - } - color="blue" - variant="light" - > + } color="blue" variant="light"> {config?.mobileScannerConvertToPdf !== false ? t( - 'mobileUpload.description', - 'Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.' + "mobileUpload.description", + "Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.", ) - : t( - 'mobileUpload.descriptionNoConvert', - 'Scan this QR code with your mobile device to upload photos.' - )} + : t("mobileUpload.descriptionNoConvert", "Scan this QR code with your mobile device to upload photos.")} {showExpiryWarning && timeRemaining !== null && ( } - title={t('mobileUpload.expiryWarning', 'Session Expiring Soon')} + icon={} + title={t("mobileUpload.expiryWarning", "Session Expiring Soon")} color="orange" > {t( - 'mobileUpload.expiryWarningMessage', - 'This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.', - { seconds: Math.ceil(timeRemaining / 1000) } + "mobileUpload.expiryWarningMessage", + "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.", + { seconds: Math.ceil(timeRemaining / 1000) }, )} @@ -316,41 +318,41 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: {error && ( } - title={t('mobileUpload.error', 'Connection Error')} + icon={} + title={t("mobileUpload.error", "Connection Error")} color="red" > {error} )} - + {filesReceived > 0 && ( - }> - {t('mobileUpload.filesReceived', '{{count}} file(s) received', { count: filesReceived })} + }> + {t("mobileUpload.filesReceived", "{{count}} file(s) received", { count: filesReceived })} )} - + {config?.mobileScannerConvertToPdf !== false ? t( - 'mobileUpload.instructions', - 'Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.' + "mobileUpload.instructions", + "Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.", ) : t( - 'mobileUpload.instructionsNoConvert', - 'Open the camera app on your phone and scan this code. Files will be uploaded through the server.' + "mobileUpload.instructionsNoConvert", + "Open the camera app on your phone and scan this code. Files will be uploaded through the server.", )} @@ -358,9 +360,9 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: size="xs" c="dimmed" style={{ - wordBreak: 'break-all', - textAlign: 'center', - fontFamily: 'monospace', + wordBreak: "break-all", + textAlign: "center", + fontFamily: "monospace", }} > {mobileUrl} diff --git a/frontend/src/core/components/shared/MultiSelectControls.tsx b/frontend/src/core/components/shared/MultiSelectControls.tsx index b6e0b24b9f..856c971130 100644 --- a/frontend/src/core/components/shared/MultiSelectControls.tsx +++ b/frontend/src/core/components/shared/MultiSelectControls.tsx @@ -16,65 +16,43 @@ const MultiSelectControls = ({ onOpenInFileEditor, onOpenInPageEditor, onAddToUpload, - onDeleteAll + onDeleteAll, }: MultiSelectControlsProps) => { const { t } = useTranslation(); if (selectedCount === 0) return null; return ( - + {selectedCount} {t("fileManager.filesSelected", "files selected")} - {onAddToUpload && ( - )} {onOpenInFileEditor && ( - )} {onOpenInPageEditor && ( - )} {onDeleteAll && ( - )} diff --git a/frontend/src/core/components/shared/NavigationWarningModal.tsx b/frontend/src/core/components/shared/NavigationWarningModal.tsx index 8e80b5d771..d35f2a470c 100644 --- a/frontend/src/core/components/shared/NavigationWarningModal.tsx +++ b/frontend/src/core/components/shared/NavigationWarningModal.tsx @@ -86,33 +86,55 @@ const NavigationWarningModal = () => { zIndex={Z_INDEX_TOAST} > - - - {t("unsavedChanges", "You have unsaved changes to your PDF.")} - - - {t("areYouSure", "Are you sure you want to leave?")} - + + + {t("unsavedChanges", "You have unsaved changes to your PDF.")} + + + {t("areYouSure", "Are you sure you want to leave?")} + {/* Desktop layout: 2 groups side by side */} - - {hasApply && ( - )} {hasExport && ( - )} @@ -121,19 +143,41 @@ const NavigationWarningModal = () => { {/* Mobile layout: centered stack of 4 buttons */} - - {hasApply && ( - )} {hasExport && ( - )} diff --git a/frontend/src/core/components/shared/ObscuredOverlay.tsx b/frontend/src/core/components/shared/ObscuredOverlay.tsx index 2329d624dc..592a79befe 100644 --- a/frontend/src/core/components/shared/ObscuredOverlay.tsx +++ b/frontend/src/core/components/shared/ObscuredOverlay.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import styles from '@app/components/shared/ObscuredOverlay/ObscuredOverlay.module.css'; +import React from "react"; +import styles from "@app/components/shared/ObscuredOverlay/ObscuredOverlay.module.css"; type ObscuredOverlayProps = { obscured: boolean; @@ -30,11 +30,7 @@ export default function ObscuredOverlay({ }} >
- {overlayMessage && ( -
- {overlayMessage} -
- )} + {overlayMessage &&
{overlayMessage}
} {buttonText && onButtonClick && (
); } - - diff --git a/frontend/src/core/components/shared/PageEditorFileDropdown.tsx b/frontend/src/core/components/shared/PageEditorFileDropdown.tsx index 11f9742c2d..2d850d7177 100644 --- a/frontend/src/core/components/shared/PageEditorFileDropdown.tsx +++ b/frontend/src/core/components/shared/PageEditorFileDropdown.tsx @@ -1,16 +1,16 @@ -import React from 'react'; -import { Menu, Loader, Group, Text, Checkbox } from '@mantine/core'; -import { LocalIcon } from '@app/components/shared/LocalIcon'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; -import AddIcon from '@mui/icons-material/Add'; -import FitText from '@app/components/shared/FitText'; -import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors'; -import { useFilesModalContext } from '@app/contexts/FilesModalContext'; -import { PrivateContent } from '@app/components/shared/PrivateContent'; -import { useFileItemDragDrop } from '@app/components/shared/pageEditor/useFileItemDragDrop'; +import React from "react"; +import { Menu, Loader, Group, Text, Checkbox } from "@mantine/core"; +import { LocalIcon } from "@app/components/shared/LocalIcon"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import AddIcon from "@mui/icons-material/Add"; +import FitText from "@app/components/shared/FitText"; +import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors"; +import { useFilesModalContext } from "@app/contexts/FilesModalContext"; +import { PrivateContent } from "@app/components/shared/PrivateContent"; +import { useFileItemDragDrop } from "@app/components/shared/pageEditor/useFileItemDragDrop"; -import { FileId } from '@app/types/file'; +import { FileId } from "@app/types/file"; // Local interface for PageEditor file display interface PageEditorFile { @@ -28,50 +28,36 @@ interface FileMenuItemProps { onReorder: (fromIndex: number, toIndex: number) => void; } -const FileMenuItem: React.FC = ({ - file, - index, - colorIndex, - onToggleSelection, - onReorder, -}) => { - const { - itemRef, - isDragging, - isDragOver, - dropPosition, - movedRef, - onPointerDown, - onPointerMove, - onPointerUp, - } = useFileItemDragDrop({ - fileId: file.fileId, - index, - onReorder, - }); +const FileMenuItem: React.FC = ({ file, index, colorIndex, onToggleSelection, onReorder }) => { + const { itemRef, isDragging, isDragOver, dropPosition, movedRef, onPointerDown, onPointerMove, onPointerUp } = + useFileItemDragDrop({ + fileId: file.fileId, + index, + onReorder, + }); - const itemName = file?.name || 'Untitled'; + const itemName = file?.name || "Untitled"; const fileColorBorder = getFileColorWithOpacity(colorIndex, 1); const fileColorBorderHover = getFileColorWithOpacity(colorIndex, 1.0); return (
{/* Drop indicator line */} {isDragOver && (
@@ -87,34 +73,36 @@ const FileMenuItem: React.FC = ({ onToggleSelection(file.fileId); }} style={{ - padding: '0.75rem 0.75rem', - cursor: isDragging ? 'grabbing' : 'grab', - backgroundColor: file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent', + padding: "0.75rem 0.75rem", + cursor: isDragging ? "grabbing" : "grab", + backgroundColor: file.isSelected ? "rgba(0, 0, 0, 0.05)" : "transparent", borderLeft: `6px solid ${fileColorBorder}`, opacity: isDragging ? 0.5 : 1, - transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease', - userSelect: 'none', + transition: "opacity 0.2s ease-in-out, background-color 0.15s ease", + userSelect: "none", }} onMouseEnter={(e) => { if (!isDragging) { - (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0, 0, 0, 0.05)'; + (e.currentTarget as HTMLDivElement).style.backgroundColor = "rgba(0, 0, 0, 0.05)"; (e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover; } }} onMouseLeave={(e) => { if (!isDragging) { - (e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent'; + (e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected + ? "rgba(0, 0, 0, 0.05)" + : "transparent"; (e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder; } }} > - +
@@ -125,7 +113,7 @@ const FileMenuItem: React.FC = ({ onClick={(e) => e.stopPropagation()} size="sm" /> -
+
@@ -167,24 +155,29 @@ export const PageEditorFileDropdown: React.FC = ({ return ( -
+
{switchingTo === "pageEditor" ? ( ) : ( )} - {selectedCount}/{totalCount} files selected + + {selectedCount}/{totalCount} files selected +
- + {files.map((file, index) => { const colorIndex = fileColorMap.get(file.fileId as string) ?? 0; @@ -207,23 +200,23 @@ export const PageEditorFileDropdown: React.FC = ({ openFilesModal(); }} style={{ - padding: '0.75rem 0.75rem', - marginTop: '0.5rem', - cursor: 'pointer', - backgroundColor: 'transparent', - borderTop: '1px solid var(--border-subtle)', - transition: 'background-color 0.15s ease', + padding: "0.75rem 0.75rem", + marginTop: "0.5rem", + cursor: "pointer", + backgroundColor: "transparent", + borderTop: "1px solid var(--border-subtle)", + transition: "background-color 0.15s ease", }} onMouseEnter={(e) => { - (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(59, 130, 246, 0.25)'; + (e.currentTarget as HTMLDivElement).style.backgroundColor = "rgba(59, 130, 246, 0.25)"; }} onMouseLeave={(e) => { - (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; + (e.currentTarget as HTMLDivElement).style.backgroundColor = "transparent"; }} > - - - + + + Add File diff --git a/frontend/src/core/components/shared/PageSelectionSyntaxHint.tsx b/frontend/src/core/components/shared/PageSelectionSyntaxHint.tsx index bf7e642066..c44025bf37 100644 --- a/frontend/src/core/components/shared/PageSelectionSyntaxHint.tsx +++ b/frontend/src/core/components/shared/PageSelectionSyntaxHint.tsx @@ -1,25 +1,25 @@ -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Text } from '@mantine/core'; -import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css'; -import { parseSelectionWithDiagnostics } from '@app/utils/bulkselection/parseSelection'; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Text } from "@mantine/core"; +import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css"; +import { parseSelectionWithDiagnostics } from "@app/utils/bulkselection/parseSelection"; interface PageSelectionSyntaxHintProps { input: string; /** Optional known page count; if not provided, a large max is used for syntax-only checks */ maxPages?: number; /** panel = full bulk panel style, compact = inline tool style */ - variant?: 'panel' | 'compact'; + variant?: "panel" | "compact"; } const FALLBACK_MAX_PAGES = 100000; // large upper bound for syntax validation without a document -const PageSelectionSyntaxHint = ({ input, maxPages, variant = 'panel' }: PageSelectionSyntaxHintProps) => { +const PageSelectionSyntaxHint = ({ input, maxPages, variant = "panel" }: PageSelectionSyntaxHintProps) => { const [syntaxError, setSyntaxError] = useState(null); const { t } = useTranslation(); useEffect(() => { - const text = (input || '').trim(); + const text = (input || "").trim(); if (!text) { setSyntaxError(null); return; @@ -27,21 +27,23 @@ const PageSelectionSyntaxHint = ({ input, maxPages, variant = 'panel' }: PageSel try { const { warning } = parseSelectionWithDiagnostics(text, maxPages && maxPages > 0 ? maxPages : FALLBACK_MAX_PAGES); - setSyntaxError(warning ? t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.') : null); + setSyntaxError( + warning ? t("bulkSelection.syntaxError", "There is a syntax issue. See Page Selection tips for help.") : null, + ); } catch { - setSyntaxError(t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.')); + setSyntaxError(t("bulkSelection.syntaxError", "There is a syntax issue. See Page Selection tips for help.")); } }, [input, maxPages]); if (!syntaxError) return null; return ( -
- {syntaxError} +
+ + {syntaxError} +
); }; export default PageSelectionSyntaxHint; - - diff --git a/frontend/src/core/components/shared/PrivateContent.tsx b/frontend/src/core/components/shared/PrivateContent.tsx index 3ed11bfc6d..a2b048e95c 100644 --- a/frontend/src/core/components/shared/PrivateContent.tsx +++ b/frontend/src/core/components/shared/PrivateContent.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from "react"; interface PrivateContentProps extends React.HTMLAttributes { children: React.ReactNode; @@ -23,14 +23,9 @@ interface PrivateContentProps extends React.HTMLAttributes { * preview * */ -export const PrivateContent: React.FC = ({ - children, - className = '', - style, - ...props -}) => { - const combinedClassName = `ph-no-capture${className ? ` ${className}` : ''}`; - const combinedStyle = { display: 'contents' as const, ...style }; +export const PrivateContent: React.FC = ({ children, className = "", style, ...props }) => { + const combinedClassName = `ph-no-capture${className ? ` ${className}` : ""}`; + const combinedStyle = { display: "contents" as const, ...style }; return ( diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx index 726ed19c43..db57bed92e 100644 --- a/frontend/src/core/components/shared/QuickAccessBar.tsx +++ b/frontend/src/core/components/shared/QuickAccessBar.tsx @@ -1,49 +1,52 @@ import React, { useState, useRef, forwardRef, useEffect, useMemo, useCallback } from "react"; -import { createPortal } from 'react-dom'; +import { createPortal } from "react-dom"; import { Stack, Divider, Menu, Indicator } from "@mantine/core"; -import { useTranslation } from 'react-i18next'; -import { useNavigate, useLocation } from 'react-router-dom'; -import LocalIcon from '@app/components/shared/LocalIcon'; -import SignPopout, { SIGN_REQUEST_WORKBENCH_TYPE, SESSION_DETAIL_WORKBENCH_TYPE } from '@app/components/shared/signing/SignPopout'; +import { useTranslation } from "react-i18next"; +import { useNavigate, useLocation } from "react-router-dom"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import SignPopout, { + SIGN_REQUEST_WORKBENCH_TYPE, + SESSION_DETAIL_WORKBENCH_TYPE, +} from "@app/components/shared/signing/SignPopout"; import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; -import { useFilesModalContext } from '@app/contexts/FilesModalContext'; -import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; -import { useFileSelection, useFileState } from '@app/contexts/file/fileHooks'; -import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; -import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation'; -import { handleUnlessSpecialClick } from '@app/utils/clickHandlers'; -import { ButtonConfig } from '@app/types/sidebar'; -import '@app/components/shared/quickAccessBar/QuickAccessBar.css'; -import { Tooltip } from '@app/components/shared/Tooltip'; -import AllToolsNavButton from '@app/components/shared/AllToolsNavButton'; +import { useFilesModalContext } from "@app/contexts/FilesModalContext"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { useFileSelection, useFileState } from "@app/contexts/file/fileHooks"; +import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext"; +import { useSidebarNavigation } from "@app/hooks/useSidebarNavigation"; +import { handleUnlessSpecialClick } from "@app/utils/clickHandlers"; +import { ButtonConfig } from "@app/types/sidebar"; +import "@app/components/shared/quickAccessBar/QuickAccessBar.css"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import AllToolsNavButton from "@app/components/shared/AllToolsNavButton"; import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton"; -import AppConfigModal from '@app/components/shared/AppConfigModal'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import { useGroupSigningEnabled } from '@app/hooks/useGroupSigningEnabled'; -import { useSharingEnabled } from '@app/hooks/useSharingEnabled'; +import AppConfigModal from "@app/components/shared/AppConfigModal"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled"; +import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; import { useLicenseAlert } from "@app/hooks/useLicenseAlert"; -import { requestStartTour } from '@app/constants/events'; -import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton'; -import { useToursTooltip } from '@app/components/shared/quickAccessBar/useToursTooltip'; -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 { uploadHistoryChain } from '@app/services/serverStorageUpload'; -import { fileStorage } from '@app/services/fileStorage'; -import { useFileActions } from '@app/contexts/FileContext'; -import type { FileId } from '@app/types/file'; -import type { StirlingFileStub } from '@app/types/fileContext'; -import type { SignRequestSummary } from '@app/types/signingSession'; +import { requestStartTour } from "@app/constants/events"; +import QuickAccessButton from "@app/components/shared/quickAccessBar/QuickAccessButton"; +import { useToursTooltip } from "@app/components/shared/quickAccessBar/useToursTooltip"; +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 { uploadHistoryChain } from "@app/services/serverStorageUpload"; +import { fileStorage } from "@app/services/fileStorage"; +import { useFileActions } from "@app/contexts/FileContext"; +import type { FileId } from "@app/types/file"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { SignRequestSummary } from "@app/types/signingSession"; import { isNavButtonActive, getNavButtonStyle, getActiveNavButton, -} from '@app/components/shared/quickAccessBar/QuickAccessBar'; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; -import { QuickAccessBarFooterExtensions } from '@app/components/quickAccessBar/QuickAccessBarFooterExtensions'; -import { useConfigButtonIcon } from '@app/hooks/useConfigButtonIcon'; +} from "@app/components/shared/quickAccessBar/QuickAccessBar"; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; +import { QuickAccessBarFooterExtensions } from "@app/components/quickAccessBar/QuickAccessBarFooterExtensions"; +import { useConfigButtonIcon } from "@app/hooks/useConfigButtonIcon"; const QuickAccessBar = forwardRef((_, ref) => { const { t } = useTranslation(); @@ -59,7 +62,7 @@ const QuickAccessBar = forwardRef((_, ref) => { toolRegistry, readerMode, resetTool, - toolAvailability + toolAvailability, } = useToolWorkflow(); const { selectedFiles, selectedFileIds } = useFileSelection(); const { state, selectors } = useFileState(); @@ -70,7 +73,7 @@ const QuickAccessBar = forwardRef((_, ref) => { const { config } = useAppConfig(); const licenseAlert = useLicenseAlert(); const [configModalOpen, setConfigModalOpen] = useState(false); - const [activeButton, setActiveButton] = useState('tools'); + const [activeButton, setActiveButton] = useState("tools"); const [accessMenuOpen, setAccessMenuOpen] = useState(false); const [accessInviteOpen, setAccessInviteOpen] = useState(false); const [selectedAccessFileId, setSelectedAccessFileId] = useState(null); @@ -82,11 +85,10 @@ const QuickAccessBar = forwardRef((_, ref) => { const { sharingEnabled, shareLinksEnabled } = useSharingEnabled(); const groupSigningEnabled = useGroupSigningEnabled(); const isSignWorkbenchActive = - currentWorkbench === SIGN_REQUEST_WORKBENCH_TYPE || - currentWorkbench === SESSION_DETAIL_WORKBENCH_TYPE; - const [inviteRows, setInviteRows] = useState>([ - { id: Date.now(), email: '', role: 'editor' }, - ]); + currentWorkbench === SIGN_REQUEST_WORKBENCH_TYPE || currentWorkbench === SESSION_DETAIL_WORKBENCH_TYPE; + const [inviteRows, setInviteRows] = useState< + Array<{ id: number; email: string; role: "editor" | "commenter" | "viewer"; error?: string }> + >([{ id: Date.now(), email: "", role: "editor" }]); const [isInviting, setIsInviting] = useState(false); // Sign button state @@ -99,12 +101,12 @@ const QuickAccessBar = forwardRef((_, ref) => { if (!groupSigningEnabled) return; const fetchCount = async () => { try { - const response = await apiClient.get('/api/v1/security/cert-sign/sign-requests'); - const pending = response.data.filter( - r => r.myStatus !== 'SIGNED' && r.myStatus !== 'DECLINED' - ).length; + const response = await apiClient.get("/api/v1/security/cert-sign/sign-requests"); + const pending = response.data.filter((r) => r.myStatus !== "SIGNED" && r.myStatus !== "DECLINED").length; setPendingSignCount(pending); - } catch { /* silent — avoid noisy background error toasts */ } + } catch { + /* silent — avoid noisy background error toasts */ + } }; fetchCount(); const interval = setInterval(fetchCount, 60000); @@ -116,12 +118,12 @@ const QuickAccessBar = forwardRef((_, ref) => { if (!signMenuOpen && groupSigningEnabled) { const timeout = setTimeout(async () => { try { - const response = await apiClient.get('/api/v1/security/cert-sign/sign-requests'); - const pending = response.data.filter( - r => r.myStatus !== 'SIGNED' && r.myStatus !== 'DECLINED' - ).length; + const response = await apiClient.get("/api/v1/security/cert-sign/sign-requests"); + const pending = response.data.filter((r) => r.myStatus !== "SIGNED" && r.myStatus !== "DECLINED").length; setPendingSignCount(pending); - } catch { /* silent */ } + } catch { + /* silent */ + } }, 500); return () => clearTimeout(timeout); } @@ -129,23 +131,16 @@ const QuickAccessBar = forwardRef((_, ref) => { const configButtonIcon = useConfigButtonIcon(); - const { - tooltipOpen, - manualCloseOnly, - showCloseButton, - toursMenuOpen, - setToursMenuOpen, - handleTooltipOpenChange, - } = useToursTooltip(); + const { tooltipOpen, manualCloseOnly, showCloseButton, toursMenuOpen, setToursMenuOpen, handleTooltipOpenChange } = + useToursTooltip(); - const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'; + const isRTL = typeof document !== "undefined" && document.documentElement.dir === "rtl"; const hasSelectedFiles = selectedFiles.length > 0; const selectedFileStubs = useMemo( () => selectedFileIds.map((id) => selectors.getStirlingFileStub(id)).filter((x): x is StirlingFileStub => Boolean(x)), - [selectedFileIds, selectors, state.files.byId] + [selectedFileIds, selectors, state.files.byId], ); - const selectedAccessFileStub = - selectedFileStubs.find((file) => file.id === selectedAccessFileId) || selectedFileStubs[0]; + const selectedAccessFileStub = selectedFileStubs.find((file) => file.id === selectedAccessFileId) || selectedFileStubs[0]; useEffect(() => { if (!hasSelectedFiles) { setAccessMenuOpen(false); @@ -159,7 +154,7 @@ const QuickAccessBar = forwardRef((_, ref) => { }, [hasSelectedFiles, selectedAccessFileId, selectedFiles]); const resetInviteRows = useCallback(() => { - setInviteRows([{ id: Date.now(), email: '', role: 'editor' }]); + setInviteRows([{ id: Date.now(), email: "", role: "editor" }]); }, []); useEffect(() => { @@ -176,11 +171,11 @@ const QuickAccessBar = forwardRef((_, ref) => { setAccessPopoverPosition({ top, left }); }; updatePosition(); - window.addEventListener('resize', updatePosition); - window.addEventListener('scroll', updatePosition, true); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); return () => { - window.removeEventListener('resize', updatePosition); - window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); }; }, [accessMenuOpen, isRTL, resetInviteRows]); @@ -192,77 +187,77 @@ const QuickAccessBar = forwardRef((_, ref) => { if (accessButtonRef.current?.contains(target)) return; // Check if click is inside a Mantine dropdown - const mantineDropdown = (target as Element).closest?.('.mantine-Combobox-dropdown, .mantine-Popover-dropdown'); + const mantineDropdown = (target as Element).closest?.(".mantine-Combobox-dropdown, .mantine-Popover-dropdown"); if (mantineDropdown) return; setAccessMenuOpen(false); }; const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { + if (event.key === "Escape") { setAccessMenuOpen(false); } }; - document.addEventListener('mousedown', handleOutside); - document.addEventListener('keydown', handleEscape); + document.addEventListener("mousedown", handleOutside); + document.addEventListener("keydown", handleEscape); return () => { - document.removeEventListener('mousedown', handleOutside); - document.removeEventListener('keydown', handleEscape); + document.removeEventListener("mousedown", handleOutside); + document.removeEventListener("keydown", handleEscape); }; }, [accessMenuOpen]); const shareBaseUrl = useMemo(() => { - const frontendUrl = (config?.frontendUrl || '').trim(); + const frontendUrl = (config?.frontendUrl || "").trim(); if (frontendUrl) { try { const parsed = new URL(frontendUrl); - if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { - const normalized = frontendUrl.endsWith('/') ? frontendUrl.slice(0, -1) : frontendUrl; + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl; return `${normalized}/share/`; } } catch { // invalid URL — fall through to default } } - return absoluteWithBasePath('/share/'); + return absoluteWithBasePath("/share/"); }, [config?.frontendUrl]); - const ensureStoredFile = useCallback(async (fileStub: StirlingFileStub): Promise => { - const localUpdatedAt = fileStub.createdAt ?? fileStub.lastModified ?? 0; - const isUpToDate = - Boolean(fileStub.remoteStorageId) && - Boolean(fileStub.remoteStorageUpdatedAt) && - (fileStub.remoteStorageUpdatedAt as number) >= localUpdatedAt; - if (isUpToDate && fileStub.remoteStorageId) { - return fileStub.remoteStorageId as number; - } - const originalFileId = (fileStub.originalFileId || fileStub.id) as FileId; - const remoteId = fileStub.remoteStorageId as number | undefined; - const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChain( - originalFileId, - remoteId - ); - for (const stub of chain) { - actions.updateStirlingFileStub(stub.id, { - remoteStorageId: storedId, - remoteStorageUpdatedAt: updatedAt, - remoteOwnedByCurrentUser: true, - remoteSharedViaLink: false, - }); - await fileStorage.updateFileMetadata(stub.id, { - remoteStorageId: storedId, - remoteStorageUpdatedAt: updatedAt, - remoteOwnedByCurrentUser: true, - remoteSharedViaLink: false, - }); - } - return storedId; - }, [actions]); + const ensureStoredFile = useCallback( + async (fileStub: StirlingFileStub): Promise => { + const localUpdatedAt = fileStub.createdAt ?? fileStub.lastModified ?? 0; + const isUpToDate = + Boolean(fileStub.remoteStorageId) && + Boolean(fileStub.remoteStorageUpdatedAt) && + (fileStub.remoteStorageUpdatedAt as number) >= localUpdatedAt; + if (isUpToDate && fileStub.remoteStorageId) { + return fileStub.remoteStorageId as number; + } + const originalFileId = (fileStub.originalFileId || fileStub.id) as FileId; + const remoteId = fileStub.remoteStorageId as number | undefined; + const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChain(originalFileId, remoteId); + for (const stub of chain) { + actions.updateStirlingFileStub(stub.id, { + remoteStorageId: storedId, + remoteStorageUpdatedAt: updatedAt, + remoteOwnedByCurrentUser: true, + remoteSharedViaLink: false, + }); + await fileStorage.updateFileMetadata(stub.id, { + remoteStorageId: storedId, + remoteStorageUpdatedAt: updatedAt, + remoteOwnedByCurrentUser: true, + remoteSharedViaLink: false, + }); + } + return storedId; + }, + [actions], + ); const openShareManage = useCallback(async () => { if (!sharingEnabled) { alert({ - alertType: 'warning', - title: t('storageShare.sharingDisabled', 'Sharing is disabled.'), + alertType: "warning", + title: t("storageShare.sharingDisabled", "Sharing is disabled."), expandable: false, durationMs: 2500, }); @@ -270,8 +265,8 @@ const QuickAccessBar = forwardRef((_, ref) => { } if (selectedFileStubs.length > 1) { alert({ - alertType: 'warning', - title: t('storageShare.selectSingleFile', 'Select a single file to manage sharing.'), + alertType: "warning", + title: t("storageShare.selectSingleFile", "Select a single file to manage sharing."), expandable: false, durationMs: 2500, }); @@ -279,8 +274,8 @@ const QuickAccessBar = forwardRef((_, ref) => { } if (selectedAccessFileStub?.remoteOwnedByCurrentUser === false) { alert({ - alertType: 'warning', - title: t('storageShare.ownerOnly', 'Only the owner can manage sharing.'), + alertType: "warning", + title: t("storageShare.ownerOnly", "Only the owner can manage sharing."), expandable: false, durationMs: 2500, }); @@ -293,10 +288,10 @@ const QuickAccessBar = forwardRef((_, ref) => { setAccessMenuOpen(false); setShareManageOpen(true); } catch (error) { - console.error('Failed to upload file for sharing:', error); + console.error("Failed to upload file for sharing:", error); alert({ - alertType: 'warning', - title: t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.'), + alertType: "warning", + title: t("storageUpload.failure", "Upload failed. Please check your login and storage settings."), expandable: false, durationMs: 3000, }); @@ -304,22 +299,20 @@ const QuickAccessBar = forwardRef((_, ref) => { }, [ensureStoredFile, selectedAccessFileStub, selectedFileStubs.length, sharingEnabled, t]); const handleInviteRowChange = useCallback( - (id: number, updates: Partial<{ email: string; role: 'editor' | 'commenter' | 'viewer'; error?: string }>) => { + (id: number, updates: Partial<{ email: string; role: "editor" | "commenter" | "viewer"; error?: string }>) => { setInviteRows((prev) => prev.map((row) => { if (row.id !== id) return row; - const nextError = Object.prototype.hasOwnProperty.call(updates, 'error') - ? updates.error - : row.error; + const nextError = Object.prototype.hasOwnProperty.call(updates, "error") ? updates.error : row.error; return { ...row, ...updates, error: nextError }; - }) + }), ); }, - [] + [], ); const handleAddInviteRow = useCallback(() => { - setInviteRows((prev) => [...prev, { id: Date.now(), email: '', role: 'editor' }]); + setInviteRows((prev) => [...prev, { id: Date.now(), email: "", role: "editor" }]); }, []); const handleRemoveInviteRow = useCallback((id: number) => { @@ -330,8 +323,8 @@ const QuickAccessBar = forwardRef((_, ref) => { if (!selectedAccessFileStub) return; if (selectedAccessFileStub.remoteOwnedByCurrentUser === false) { alert({ - alertType: 'warning', - title: t('storageShare.ownerOnly', 'Only the owner can manage sharing.'), + alertType: "warning", + title: t("storageShare.ownerOnly", "Only the owner can manage sharing."), expandable: false, durationMs: 2500, }); @@ -341,7 +334,7 @@ const QuickAccessBar = forwardRef((_, ref) => { const trimmed = row.email.trim(); let error: string | undefined; if (!trimmed) { - error = t('storageShare.invalidUsername', 'Enter a valid username or email address.'); + error = t("storageShare.invalidUsername", "Enter a valid username or email address."); } return { ...row, email: trimmed, error }; }); @@ -359,18 +352,18 @@ const QuickAccessBar = forwardRef((_, ref) => { }); } alert({ - alertType: 'success', - title: t('storageShare.userAdded', 'User added to shared list.'), + alertType: "success", + title: t("storageShare.userAdded", "User added to shared list."), expandable: false, durationMs: 2500, }); setAccessInviteOpen(false); resetInviteRows(); } catch (error) { - console.error('Failed to send invite:', error); + console.error("Failed to send invite:", error); alert({ - alertType: 'warning', - title: t('storageShare.userAddFailed', 'Unable to share with that user.'), + alertType: "warning", + title: t("storageShare.userAddFailed", "Unable to share with that user."), expandable: false, durationMs: 3000, }); @@ -383,8 +376,8 @@ const QuickAccessBar = forwardRef((_, ref) => { if (!selectedAccessFileStub) return; if (!shareLinksEnabled) { alert({ - alertType: 'warning', - title: t('storageShare.linksDisabled', 'Share links are disabled.'), + alertType: "warning", + title: t("storageShare.linksDisabled", "Share links are disabled."), expandable: false, durationMs: 2500, }); @@ -392,8 +385,8 @@ const QuickAccessBar = forwardRef((_, ref) => { } if (selectedFileStubs.length > 1) { alert({ - alertType: 'warning', - title: t('storageShare.selectSingleFile', 'Select a single file to copy a link.'), + alertType: "warning", + title: t("storageShare.selectSingleFile", "Select a single file to copy a link."), expandable: false, durationMs: 2500, }); @@ -401,8 +394,8 @@ const QuickAccessBar = forwardRef((_, ref) => { } if (selectedAccessFileStub?.remoteOwnedByCurrentUser === false) { alert({ - alertType: 'warning', - title: t('storageShare.ownerOnly', 'Only the owner can manage sharing.'), + alertType: "warning", + title: t("storageShare.ownerOnly", "Only the owner can manage sharing."), expandable: false, durationMs: 2500, }); @@ -412,10 +405,10 @@ const QuickAccessBar = forwardRef((_, ref) => { try { await ensureStoredFile(selectedAccessFileStub); } catch (error) { - console.error('Failed to upload file for sharing:', error); + console.error("Failed to upload file for sharing:", error); alert({ - alertType: 'warning', - title: t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.'), + alertType: "warning", + title: t("storageUpload.failure", "Upload failed. Please check your login and storage settings."), expandable: false, durationMs: 3000, }); @@ -424,15 +417,14 @@ const QuickAccessBar = forwardRef((_, ref) => { } try { const storedId = await ensureStoredFile(selectedAccessFileStub); - const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>( - `/api/v1/storage/files/${storedId}`, - { suppressErrorToast: true } - ); + const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>(`/api/v1/storage/files/${storedId}`, { + suppressErrorToast: true, + }); const links = response.data?.shareLinks ?? []; let token = links[links.length - 1]?.token; if (!token) { const shareResponse = await apiClient.post(`/api/v1/storage/files/${storedId}/shares/links`, { - accessRole: 'editor', + accessRole: "editor", }); token = shareResponse.data?.token; if (token) { @@ -442,8 +434,8 @@ const QuickAccessBar = forwardRef((_, ref) => { } if (!token) { alert({ - alertType: 'warning', - title: t('storageShare.failure', 'Unable to generate a share link. Please try again.'), + alertType: "warning", + title: t("storageShare.failure", "Unable to generate a share link. Please try again."), expandable: false, durationMs: 2500, }); @@ -451,26 +443,25 @@ const QuickAccessBar = forwardRef((_, ref) => { } 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, }); } }; - // Open modal if URL is at /settings/* useEffect(() => { - const isSettings = location.pathname.startsWith('/settings'); + const isSettings = location.pathname.startsWith("/settings"); setConfigModalOpen(isSettings); }, [location.pathname]); @@ -485,12 +476,13 @@ const QuickAccessBar = forwardRef((_, ref) => { // Helper function to render navigation buttons with URL support const renderNavButton = (config: ButtonConfig, index: number, shouldGuardNavigation = false) => { - const isActive = !isSignWorkbenchActive && isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView); + const isActive = + !isSignWorkbenchActive && + isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView); // Check if this button has URL navigation support - const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate') - ? getToolNavigation(config.id) - : null; + const navProps = + config.type === "navigation" && (config.id === "read" || config.id === "automate") ? getToolNavigation(config.id) : null; const handleClick = (e?: React.MouseEvent) => { // If there are unsaved changes and this button should guard navigation, show warning modal @@ -509,15 +501,17 @@ const QuickAccessBar = forwardRef((_, ref) => { }; const buttonStyle = isSignWorkbenchActive - ? { backgroundColor: 'var(--icon-inactive-bg)', color: 'var(--icon-inactive-color)', border: 'none', borderRadius: '0.5rem' } + ? { + backgroundColor: "var(--icon-inactive-bg)", + color: "var(--icon-inactive-color)", + border: "none", + borderRadius: "0.5rem", + } : getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView); // Render navigation button with conditional URL support return ( -
+
((_, ref) => { ariaLabel={config.name} backgroundColor={buttonStyle.backgroundColor} color={buttonStyle.color} - component={navProps ? 'a' : 'button'} + component={navProps ? "a" : "button"} dataTestId={`${config.id}-button`} dataTour={`${config.id}-button`} /> @@ -535,54 +529,58 @@ const QuickAccessBar = forwardRef((_, ref) => { ); }; - const mainButtons: ButtonConfig[] = useMemo(() => [ - { - id: 'read', - name: t("quickAccess.reader", "Reader"), - icon: , - size: 'md' as const, - isRound: false, - type: 'navigation' as const, - onClick: () => { - setActiveButton('read'); - handleReaderToggle(); - } - }, - { - id: 'automate', - name: t("quickAccess.automate", "Automate"), - icon: , - size: 'md' as const, - isRound: false, - type: 'navigation' as const, - onClick: () => { - setActiveButton('automate'); - // If already on automate tool, reset it directly - if (selectedToolKey === 'automate') { - resetTool('automate'); - } else { - handleToolSelect('automate'); - } - } - }, - ].filter(button => { - // Filter out buttons for disabled tools - // 'read' is always available (viewer mode) - if (button.id === 'read') return true; - // Check if tool is actually available (not just present in registry) - const availability = toolAvailability[button.id as keyof typeof toolAvailability]; - return availability?.available !== false; - }), [t, setActiveButton, handleReaderToggle, selectedToolKey, resetTool, handleToolSelect, toolAvailability]); + const mainButtons: ButtonConfig[] = useMemo( + () => + [ + { + id: "read", + name: t("quickAccess.reader", "Reader"), + icon: , + size: "md" as const, + isRound: false, + type: "navigation" as const, + onClick: () => { + setActiveButton("read"); + handleReaderToggle(); + }, + }, + { + id: "automate", + name: t("quickAccess.automate", "Automate"), + icon: , + size: "md" as const, + isRound: false, + type: "navigation" as const, + onClick: () => { + setActiveButton("automate"); + // If already on automate tool, reset it directly + if (selectedToolKey === "automate") { + resetTool("automate"); + } else { + handleToolSelect("automate"); + } + }, + }, + ].filter((button) => { + // Filter out buttons for disabled tools + // 'read' is always available (viewer mode) + if (button.id === "read") return true; + // Check if tool is actually available (not just present in registry) + const availability = toolAvailability[button.id as keyof typeof toolAvailability]; + return availability?.available !== false; + }), + [t, setActiveButton, handleReaderToggle, selectedToolKey, resetTool, handleToolSelect, toolAvailability], + ); const middleButtons: ButtonConfig[] = [ { - id: 'files', + id: "files", name: t("quickAccess.files", "Files"), icon: , isRound: true, - size: 'md', - type: 'modal', - onClick: handleFilesButtonClick + size: "md", + type: "modal", + onClick: handleFilesButtonClick, }, ]; //TODO: Activity @@ -598,51 +596,50 @@ const QuickAccessBar = forwardRef((_, ref) => { // Determine if settings button should be hidden // Hide when login is disabled AND showSettingsWhenNoLogin is false - const shouldHideSettingsButton = - config?.enableLogin === false && - config?.showSettingsWhenNoLogin === false; + const shouldHideSettingsButton = config?.enableLogin === false && config?.showSettingsWhenNoLogin === false; const bottomButtons: ButtonConfig[] = [ { - id: 'help', + id: "help", name: t("quickAccess.tours", "Tours"), icon: , isRound: true, - size: 'md', - type: 'action', + size: "md", + type: "action", onClick: () => { // This will be overridden by the wrapper logic }, }, - ...(shouldHideSettingsButton ? [] : [{ - id: 'config', - name: t("quickAccess.settings", "Settings"), - icon: configButtonIcon ?? , - size: 'md' as const, - type: 'modal' as const, - onClick: () => { - navigate('/settings/overview'); - setConfigModalOpen(true); - } - } as ButtonConfig]) + ...(shouldHideSettingsButton + ? [] + : [ + { + id: "config", + name: t("quickAccess.settings", "Settings"), + icon: configButtonIcon ?? , + size: "md" as const, + type: "modal" as const, + onClick: () => { + navigate("/settings/overview"); + setConfigModalOpen(true); + }, + } as ButtonConfig, + ]), ]; - return (
{/* Fixed header outside scrollable area */}
-
- {/* Scrollable content area */}
((_, ref) => { {mainButtons.map((config, index) => ( - {renderNavButton(config, index, config.id === 'read' || config.id === 'automate')} + {renderNavButton(config, index, config.id === "read" || config.id === "automate")} ))} @@ -665,57 +662,45 @@ const QuickAccessBar = forwardRef((_, ref) => { {/* Middle section */} {middleButtons.length > 0 && ( <> - + {middleButtons.map((config, index) => ( - - {renderNavButton(config, index)} - + {renderNavButton(config, index)} ))} {hasSelectedFiles && sharingEnabled && (
} - label={t('quickAccess.access', 'Access')} + label={t("quickAccess.access", "Access")} isActive={!isSignWorkbenchActive && accessMenuOpen} onClick={() => { setAccessMenuOpen((prev) => !prev); }} - ariaLabel={t('quickAccess.access', 'Access')} + ariaLabel={t("quickAccess.access", "Access")} dataTestId="access-button" />
)} {groupSigningEnabled && ( -
+
{pendingSignCount > 0 ? ( - + } - label={t('quickAccess.sign', 'Sign')} + label={t("quickAccess.sign", "Sign")} isActive={signMenuOpen || isSignWorkbenchActive} onClick={() => setSignMenuOpen((prev) => !prev)} - ariaLabel={t('quickAccess.sign', 'Sign')} + ariaLabel={t("quickAccess.sign", "Sign")} dataTestId="sign-button" /> ) : ( } - label={t('quickAccess.sign', 'Sign')} + label={t("quickAccess.sign", "Sign")} isActive={signMenuOpen || isSignWorkbenchActive} onClick={() => setSignMenuOpen((prev) => !prev)} - ariaLabel={t('quickAccess.sign', 'Sign')} + ariaLabel={t("quickAccess.sign", "Sign")} dataTestId="sign-button" /> )} @@ -734,39 +719,46 @@ const QuickAccessBar = forwardRef((_, ref) => { {bottomButtons.map((buttonConfig, index) => { // Handle help button with menu or direct action - if (buttonConfig.id === 'help') { + if (buttonConfig.id === "help") { const isAdmin = config?.isAdmin === true; const toursTooltipContent = isAdmin - ? t('quickAccess.toursTooltip.admin', 'Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour.') - : t('quickAccess.toursTooltip.user', 'Watch walkthroughs here: Tools tour and the New V2 layout tour.'); + ? t( + "quickAccess.toursTooltip.admin", + "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour.", + ) + : t("quickAccess.toursTooltip.user", "Watch walkthroughs here: Tools tour and the New V2 layout tour."); const tourItems = [ { - key: 'whatsnew', + key: "whatsnew", icon: , title: t("quickAccess.helpMenu.whatsNewTour", "See what's new in V2"), description: t("quickAccess.helpMenu.whatsNewTourDesc", "Tour the updated layout"), - onClick: () => requestStartTour('whatsnew'), + onClick: () => requestStartTour("whatsnew"), }, { - key: 'tools', + key: "tools", icon: , title: t("quickAccess.helpMenu.toolsTour", "Tools Tour"), description: t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do"), - onClick: () => requestStartTour('tools'), + onClick: () => requestStartTour("tools"), }, - ...(isAdmin ? [{ - key: 'admin', - icon: , - title: t("quickAccess.helpMenu.adminTour", "Admin Tour"), - description: t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features"), - onClick: () => requestStartTour('admin'), - }] : []), + ...(isAdmin + ? [ + { + key: "admin", + icon: , + title: t("quickAccess.helpMenu.adminTour", "Admin Tour"), + description: t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features"), + onClick: () => requestStartTour("admin"), + }, + ] + : []), ]; const helpButtonNode = (
((_, ref) => { {tourItems.map((item) => ( - +
-
- {item.title} -
-
- {item.description} -
+
{item.title}
+
{item.description}
))} @@ -819,20 +803,12 @@ const QuickAccessBar = forwardRef((_, ref) => { const buttonNode = renderNavButton(buttonConfig, index); const shouldShowSettingsBadge = - buttonConfig.id === 'config' && - licenseAlert.active && - licenseAlert.audience === 'admin'; + buttonConfig.id === "config" && licenseAlert.active && licenseAlert.audience === "admin"; return ( {shouldShowSettingsBadge ? ( - + {buttonNode} ) : ( @@ -845,10 +821,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
- setConfigModalOpen(false)} - /> + setConfigModalOpen(false)} /> {selectedAccessFileStub && ( ((_, ref) => { file={selectedAccessFileStub} /> )} - {hasSelectedFiles && typeof document !== 'undefined' && createPortal( -
-
-
- -
- {accessInviteOpen - ? t('quickAccess.accessInviteTitle', 'Invite People') - : t('quickAccess.accessTitle', 'Document Access')} -
-
- {!accessInviteOpen && ( + {hasSelectedFiles && + typeof document !== "undefined" && + createPortal( +
+
+
+ +
+ {accessInviteOpen + ? t("quickAccess.accessInviteTitle", "Invite People") + : t("quickAccess.accessTitle", "Document Access")} +
+
+ {!accessInviteOpen && ( + + )} - )} - -
-
- -
-
-
-
- {t('quickAccess.accessFileLabel', 'File')} -
- -
- -
- -
-
- {t('quickAccess.accessGeneral', 'General Access')} -
-
-
- -
-
-
- {t('quickAccess.accessRestricted', 'Restricted')} -
-
- {t('quickAccess.accessRestrictedHint', 'Only people with access can open')} -
-
-
-
- -
- -
-
- {t('quickAccess.accessPeople', 'People with access')} -
-
-
- {(selectedAccessFileStub?.remoteOwnerUsername || 'You').slice(0, 2).toUpperCase()} -
-
-
- {selectedAccessFileStub?.remoteOwnerUsername || t('quickAccess.accessYou', 'You')} -
-
- {selectedAccessFileStub?.name ?? t('quickAccess.accessSelectedFile', 'Selected file')} -
-
- - {t('quickAccess.accessOwner', 'Owner')} - -
-
-
-
- {t('quickAccess.accessInviteTitle', 'Invite People')} +
+
+
+
{t("quickAccess.accessFileLabel", "File")}
+ +
+ +
+ +
+
{t("quickAccess.accessGeneral", "General Access")}
+
+
+ +
+
+
{t("quickAccess.accessRestricted", "Restricted")}
+
+ {t("quickAccess.accessRestrictedHint", "Only people with access can open")} +
+
+
+
+ +
+ +
+
{t("quickAccess.accessPeople", "People with access")}
+
+
+ {(selectedAccessFileStub?.remoteOwnerUsername || "You").slice(0, 2).toUpperCase()} +
+
+
+ {selectedAccessFileStub?.remoteOwnerUsername || t("quickAccess.accessYou", "You")} +
+
+ {selectedAccessFileStub?.name ?? t("quickAccess.accessSelectedFile", "Selected file")} +
+
+ {t("quickAccess.accessOwner", "Owner")} +
- {inviteRows.map((row) => ( -
-
- - - handleInviteRowChange(row.id, { email: event.target.value, error: undefined }) - } - /> - {row.error && ( -
{row.error}
- )} -
-
- - handleInviteRowChange(row.id, { email: event.target.value, error: undefined })} + /> + {row.error &&
{row.error}
} +
+
+ + +
+
- -
- ))} - + ))} + +
-
- -
- {accessInviteOpen ? ( - <> - - {shareLinksEnabled && ( - - )} - - ) : ( - <> - {sharingEnabled && ( +
+ {accessInviteOpen ? ( + <> - )} - {shareLinksEnabled && ( - - )} - - )} + {shareLinksEnabled && ( + + )} + + ) : ( + <> + {sharingEnabled && ( + + )} + {shareLinksEnabled && ( + + )} + + )} +
-
-
, - document.body - )} +
, + document.body, + )} {/* Sign Popover */} ((_, ref) => { ); }); -QuickAccessBar.displayName = 'QuickAccessBar'; +QuickAccessBar.displayName = "QuickAccessBar"; export default QuickAccessBar; diff --git a/frontend/src/core/components/shared/RainbowThemeProvider.tsx b/frontend/src/core/components/shared/RainbowThemeProvider.tsx index 992aa79b5c..a3fa5e04af 100644 --- a/frontend/src/core/components/shared/RainbowThemeProvider.tsx +++ b/frontend/src/core/components/shared/RainbowThemeProvider.tsx @@ -1,12 +1,12 @@ -import { createContext, useContext, ReactNode } from 'react'; -import { MantineProvider } from '@mantine/core'; -import { useRainbowTheme } from '@app/hooks/useRainbowTheme'; -import { mantineTheme } from '@app/theme/mantineTheme'; -import rainbowStyles from '@app/styles/rainbow.module.css'; -import { ToastProvider } from '@app/components/toast'; -import ToastRenderer from '@app/components/toast/ToastRenderer'; -import { ToastPortalBinder } from '@app/components/toast'; -import type { ThemeMode } from '@app/constants/theme'; +import { createContext, useContext, ReactNode } from "react"; +import { MantineProvider } from "@mantine/core"; +import { useRainbowTheme } from "@app/hooks/useRainbowTheme"; +import { mantineTheme } from "@app/theme/mantineTheme"; +import rainbowStyles from "@app/styles/rainbow.module.css"; +import { ToastProvider } from "@app/components/toast"; +import ToastRenderer from "@app/components/toast/ToastRenderer"; +import { ToastPortalBinder } from "@app/components/toast"; +import type { ThemeMode } from "@app/constants/theme"; interface RainbowThemeContextType { themeMode: ThemeMode; @@ -22,7 +22,7 @@ const RainbowThemeContext = createContext(null); export function useRainbowThemeContext() { const context = useContext(RainbowThemeContext); if (!context) { - throw new Error('useRainbowThemeContext must be used within RainbowThemeProvider'); + throw new Error("useRainbowThemeContext must be used within RainbowThemeProvider"); } return context; } @@ -35,19 +35,12 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) { const rainbowTheme = useRainbowTheme(); // Determine the Mantine color scheme - const mantineColorScheme = rainbowTheme.themeMode === 'rainbow' ? 'dark' : rainbowTheme.themeMode; + const mantineColorScheme = rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode; return ( - -
+ +
{children} diff --git a/frontend/src/core/components/shared/RightRail.tsx b/frontend/src/core/components/shared/RightRail.tsx index 8001b17f73..639a354e73 100644 --- a/frontend/src/core/components/shared/RightRail.tsx +++ b/frontend/src/core/components/shared/RightRail.tsx @@ -1,40 +1,40 @@ -import React, { useCallback, useMemo } from 'react'; -import { ActionIcon, Divider } from '@mantine/core'; -import '@app/components/shared/rightRail/RightRail.css'; -import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; -import { useRightRail } from '@app/contexts/RightRailContext'; -import { useFileState, useFileSelection, useFileActions } from '@app/contexts/FileContext'; -import { isStirlingFile } from '@app/types/fileContext'; -import { useNavigationState } from '@app/contexts/NavigationContext'; -import { useTranslation } from 'react-i18next'; -import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; -import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; +import React, { useCallback, useMemo } from "react"; +import { ActionIcon, Divider } from "@mantine/core"; +import "@app/components/shared/rightRail/RightRail.css"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { useRightRail } from "@app/contexts/RightRailContext"; +import { useFileState, useFileSelection, useFileActions } from "@app/contexts/FileContext"; +import { isStirlingFile } from "@app/types/fileContext"; +import { useNavigationState } from "@app/contexts/NavigationContext"; +import { useTranslation } from "react-i18next"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; +import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; -import LanguageSelector from '@app/components/shared/LanguageSelector'; -import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider'; -import { Tooltip } from '@app/components/shared/Tooltip'; -import { ViewerContext } from '@app/contexts/ViewerContext'; -import LocalIcon from '@app/components/shared/LocalIcon'; -import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions'; -import DarkModeIcon from '@mui/icons-material/DarkMode'; -import LightModeIcon from '@mui/icons-material/LightMode'; +import LanguageSelector from "@app/components/shared/LanguageSelector"; +import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import { ViewerContext } from "@app/contexts/ViewerContext"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { RightRailFooterExtensions } from "@app/components/rightRail/RightRailFooterExtensions"; +import DarkModeIcon from "@mui/icons-material/DarkMode"; +import LightModeIcon from "@mui/icons-material/LightMode"; -import { useSidebarContext } from '@app/contexts/SidebarContext'; -import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail'; -import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide'; -import { downloadFile } from '@app/services/downloadService'; +import { useSidebarContext } from "@app/contexts/SidebarContext"; +import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from "@app/types/rightRail"; +import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide"; +import { downloadFile } from "@app/services/downloadService"; -const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom']; +const SECTION_ORDER: RightRailSection[] = ["top", "middle", "bottom"]; function renderWithTooltip( node: React.ReactNode, tooltip: React.ReactNode | undefined, - position: 'left' | 'right', - offset: number + position: "left" | "right", + offset: number, ) { if (!tooltip) return node; - const portalTarget = typeof document !== 'undefined' ? document.body : undefined; + const portalTarget = typeof document !== "undefined" ? document.body : undefined; return ( @@ -54,7 +54,7 @@ export default function RightRail() { const { buttons, actions, allButtonsDisabled } = useRightRail(); const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow(); - const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker'; + const disableForFullscreen = toolPanelMode === "fullscreen" && leftPanelView === "toolPicker"; const { workbench: currentView } = useNavigationState(); @@ -66,24 +66,22 @@ export default function RightRail() { const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0; const totalItems = useMemo(() => { - if (currentView === 'pageEditor') return pageEditorTotalPages; + if (currentView === "pageEditor") return pageEditorTotalPages; return activeFiles.length; }, [currentView, pageEditorTotalPages, activeFiles.length]); const selectedCount = useMemo(() => { - if (currentView === 'pageEditor') { + if (currentView === "pageEditor") { return pageEditorSelectedCount; } return selectedFileIds.length; }, [currentView, pageEditorSelectedCount, selectedFileIds.length]); const sectionsWithButtons = useMemo(() => { - return SECTION_ORDER - .map(section => { - const sectionButtons = buttons.filter(btn => (btn.section ?? 'top') === section && (btn.visible ?? true)); - return { section, buttons: sectionButtons }; - }) - .filter(entry => entry.buttons.length > 0); + return SECTION_ORDER.map((section) => { + const sectionButtons = buttons.filter((btn) => (btn.section ?? "top") === section && (btn.visible ?? true)); + return { section, buttons: sectionButtons }; + }).filter((entry) => entry.buttons.length > 0); }, [buttons]); const renderButton = useCallback( @@ -110,20 +108,19 @@ export default function RightRail() { if (!btn.icon) return null; - const ariaLabel = - btn.ariaLabel || (typeof btn.tooltip === 'string' ? (btn.tooltip as string) : undefined); - const className = ['right-rail-icon', btn.className].filter(Boolean).join(' '); + const ariaLabel = btn.ariaLabel || (typeof btn.tooltip === "string" ? (btn.tooltip as string) : undefined); + const className = ["right-rail-icon", btn.className].filter(Boolean).join(" "); const buttonNode = ( {btn.icon} @@ -131,12 +128,12 @@ export default function RightRail() { return renderWithTooltip(buttonNode, btn.tooltip, tooltipPosition, tooltipOffset); }, - [actions, allButtonsDisabled, disableForFullscreen, tooltipPosition, tooltipOffset] + [actions, allButtonsDisabled, disableForFullscreen, tooltipPosition, tooltipOffset], ); const handleExportAll = useCallback( async (forceNewFile = false) => { - if (currentView === 'viewer') { + if (currentView === "viewer") { const buffer = await viewerContext?.exportActions?.saveAsCopy?.(); if (!buffer) return; const fileToExport = selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0]; @@ -144,7 +141,7 @@ export default function RightRail() { const stub = isStirlingFile(fileToExport) ? selectors.getStirlingFileStub(fileToExport.fileId) : undefined; try { const result = await downloadFile({ - data: new Blob([buffer], { type: 'application/pdf' }), + data: new Blob([buffer], { type: "application/pdf" }), filename: fileToExport.name, localPath: forceNewFile ? undefined : stub?.localFilePath, }); @@ -155,12 +152,12 @@ export default function RightRail() { }); } } catch (error) { - console.error('[RightRail] Failed to export viewer file:', error); + console.error("[RightRail] Failed to export viewer file:", error); } return; } - if (currentView === 'pageEditor') { + if (currentView === "pageEditor") { pageEditorFunctions?.onExportAll?.(); return; } @@ -184,27 +181,19 @@ export default function RightRail() { }); } } catch (error) { - console.error('[RightRail] Failed to export file:', file.name, error); + console.error("[RightRail] Failed to export file:", file.name, error); } } } }, - [ - currentView, - selectedFiles, - activeFiles, - pageEditorFunctions, - viewerContext, - selectors, - fileActions, - ] + [currentView, selectedFiles, activeFiles, pageEditorFunctions, viewerContext, selectors, fileActions], ); const downloadTooltip = useMemo(() => { - if (currentView === 'pageEditor') { - return t('rightRail.exportAll', 'Export PDF'); + if (currentView === "pageEditor") { + return t("rightRail.exportAll", "Export PDF"); } - if (currentView === 'viewer') { + if (currentView === "viewer") { return terminology.download; } if (selectedCount > 0) { @@ -223,11 +212,7 @@ export default function RightRail() { const content = renderButton(btn); if (!content) return null; return ( -
+
{content}
); @@ -236,31 +221,24 @@ export default function RightRail() { ))} -
+
{renderWithTooltip( - - {themeMode === 'dark' ? ( - + + {themeMode === "dark" ? ( + ) : ( - + )} , - t('rightRail.toggleTheme', 'Toggle Theme'), + t("rightRail.toggleTheme", "Toggle Theme"), tooltipPosition, - tooltipOffset + tooltipOffset, )} - + {renderWithTooltip( handleExportAll()} - disabled={ - disableForFullscreen || - (currentView !== 'viewer' && (totalItems === 0 || allButtonsDisabled)) - } + disabled={disableForFullscreen || (currentView !== "viewer" && (totalItems === 0 || allButtonsDisabled))} > , downloadTooltip, tooltipPosition, - tooltipOffset + tooltipOffset, )} {icons.saveAsIconName && renderWithTooltip( @@ -286,16 +261,13 @@ export default function RightRail() { radius="md" className="right-rail-icon" onClick={() => handleExportAll(true)} - disabled={ - disableForFullscreen || - (currentView !== 'viewer' && (totalItems === 0 || allButtonsDisabled)) - } + disabled={disableForFullscreen || (currentView !== "viewer" && (totalItems === 0 || allButtonsDisabled))} > , - t('rightRail.saveAs', 'Save As'), + t("rightRail.saveAs", "Save As"), tooltipPosition, - tooltipOffset + tooltipOffset, )}
diff --git a/frontend/src/core/components/shared/ShareFileModal.tsx b/frontend/src/core/components/shared/ShareFileModal.tsx index 3cdcf99245..757aa86bc7 100644 --- a/frontend/src/core/components/shared/ShareFileModal.tsx +++ b/frontend/src/core/components/shared/ShareFileModal.tsx @@ -1,19 +1,19 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from '@mantine/core'; -import LinkIcon from '@mui/icons-material/Link'; -import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded'; -import { useTranslation } from 'react-i18next'; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from "@mantine/core"; +import LinkIcon from "@mui/icons-material/Link"; +import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded"; +import { useTranslation } from "react-i18next"; -import apiClient from '@app/services/apiClient'; -import { absoluteWithBasePath } from '@app/constants/app'; -import { alert } from '@app/components/toast'; -import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import type { StirlingFileStub } from '@app/types/fileContext'; -import { uploadHistoryChain } from '@app/services/serverStorageUpload'; -import { fileStorage } from '@app/services/fileStorage'; -import { useFileActions } from '@app/contexts/FileContext'; -import type { FileId } from '@app/types/file'; +import apiClient from "@app/services/apiClient"; +import { absoluteWithBasePath } from "@app/constants/app"; +import { alert } from "@app/components/toast"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import { uploadHistoryChain } from "@app/services/serverStorageUpload"; +import { fileStorage } from "@app/services/fileStorage"; +import { useFileActions } from "@app/contexts/FileContext"; +import type { FileId } from "@app/types/file"; interface ShareFileModalProps { opened: boolean; @@ -22,12 +22,7 @@ interface ShareFileModalProps { onUploaded?: () => Promise | void; } -const ShareFileModal: React.FC = ({ - opened, - onClose, - file, - onUploaded, -}) => { +const ShareFileModal: React.FC = ({ opened, onClose, file, onUploaded }) => { const { t } = useTranslation(); const { config } = useAppConfig(); const { actions } = useFileActions(); @@ -35,7 +30,7 @@ const ShareFileModal: React.FC = ({ const [isWorking, setIsWorking] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [shareToken, setShareToken] = useState(null); - const [shareRole, setShareRole] = useState<'editor' | 'commenter' | 'viewer'>('editor'); + const [shareRole, setShareRole] = useState<"editor" | "commenter" | "viewer">("editor"); useEffect(() => { if (!opened) { @@ -47,18 +42,18 @@ const ShareFileModal: React.FC = ({ useEffect(() => { if (opened) { - setShareRole('editor'); + setShareRole("editor"); } }, [opened]); const shareUrl = useMemo(() => { - if (!shareToken) return ''; - const frontendUrl = (config?.frontendUrl || '').trim(); + if (!shareToken) return ""; + const frontendUrl = (config?.frontendUrl || "").trim(); if (frontendUrl) { try { const parsed = new URL(frontendUrl); - if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { - const normalized = frontendUrl.endsWith('/') ? frontendUrl.slice(0, -1) : frontendUrl; + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl; return `${normalized}/share/${shareToken}`; } } catch { @@ -68,18 +63,21 @@ const ShareFileModal: React.FC = ({ return absoluteWithBasePath(`/share/${shareToken}`); }, [config?.frontendUrl, shareToken]); - const createShareLink = useCallback(async (storedFileId: number) => { - const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, { - accessRole: shareRole, - }); - return response.data as { token?: string }; - }, [shareRole]); + const createShareLink = useCallback( + async (storedFileId: number) => { + const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, { + accessRole: shareRole, + }); + return response.data as { token?: string }; + }, + [shareRole], + ); const handleGenerateLink = useCallback(async () => { if (!shareLinksEnabled) { alert({ - alertType: 'warning', - title: t('storageShare.linksDisabled', 'Share links are disabled.'), + alertType: "warning", + title: t("storageShare.linksDisabled", "Share links are disabled."), expandable: false, durationMs: 2500, }); @@ -101,10 +99,7 @@ const ShareFileModal: React.FC = ({ if (!isUpToDate) { const originalFileId = (file.originalFileId || file.id) as FileId; const remoteId = file.remoteStorageId; - const { remoteId: newStoredId, updatedAt, chain } = await uploadHistoryChain( - originalFileId, - remoteId - ); + const { remoteId: newStoredId, updatedAt, chain } = await uploadHistoryChain(originalFileId, remoteId); storedId = newStoredId; for (const stub of chain) { @@ -124,14 +119,14 @@ const ShareFileModal: React.FC = ({ } if (!storedId) { - throw new Error('Missing stored file ID for sharing.'); + throw new Error("Missing stored file ID for sharing."); } const shareResponse = await createShareLink(storedId); setShareToken(shareResponse.token ?? null); alert({ - alertType: 'success', - title: t('storageShare.generated', 'Share link generated'), + alertType: "success", + title: t("storageShare.generated", "Share link generated"), expandable: false, durationMs: 3000, }); @@ -143,10 +138,8 @@ const ShareFileModal: React.FC = ({ await onUploaded(); } } catch (error: any) { - console.error('Failed to generate share link:', error); - setErrorMessage( - t('storageShare.failure', 'Unable to generate a share link. Please try again.') - ); + console.error("Failed to generate share link:", error); + setErrorMessage(t("storageShare.failure", "Unable to generate a share link. Please try again.")); } finally { setIsWorking(false); } @@ -157,16 +150,16 @@ const ShareFileModal: React.FC = ({ try { await navigator.clipboard.writeText(shareUrl); 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, }); @@ -178,7 +171,7 @@ const ShareFileModal: React.FC = ({ opened={opened} onClose={onClose} centered - title={t('storageShare.title', 'Share File')} + title={t("storageShare.title", "Share File")} zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL} size="lg" overlayProps={{ blur: 6 }} @@ -188,24 +181,24 @@ const ShareFileModal: React.FC = ({ {t( - 'storageShare.description', - 'Create a share link for this file. Signed-in users with the link can access it.' + "storageShare.description", + "Create a share link for this file. Signed-in users with the link can access it.", )} - {t('storageShare.fileLabel', 'File')}: {file.name} + {t("storageShare.fileLabel", "File")}: {file.name} {errorMessage && ( - + {errorMessage} )} {!shareLinksEnabled && ( - - {t('storageShare.linksDisabledBody', 'Share links are disabled by your server settings.')} + + {t("storageShare.linksDisabledBody", "Share links are disabled by your server settings.")} )} @@ -215,7 +208,7 @@ const ShareFileModal: React.FC = ({ = ({ leftSection={} onClick={handleCopyLink} > - {t('storageShare.copy', 'Copy')} + {t("storageShare.copy", "Copy")} } /> @@ -234,22 +227,22 @@ const ShareFileModal: React.FC = ({ - {t('storageShare.linkAccessTitle', 'Share link access')} + {t("storageShare.linkAccessTitle", "Share link access")} setShareRole((value as typeof shareRole) || 'editor')} + onChange={(value) => setShareRole((value as typeof shareRole) || "editor")} comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }} data={[ - { value: 'editor', label: t('storageShare.roleEditor', 'Editor') }, - { value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') }, - { value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') }, + { value: "editor", label: t("storageShare.roleEditor", "Editor") }, + { value: "commenter", label: t("storageShare.roleCommenter", "Commenter") }, + { value: "viewer", label: t("storageShare.roleViewer", "Viewer") }, ]} /> - {shareRole === 'commenter' && ( + {shareRole === "commenter" && ( - {t('storageShare.commenterHint', 'Commenting is coming soon.')} + {t("storageShare.commenterHint", "Commenting is coming soon.")} )} @@ -454,7 +432,7 @@ const ShareManagementModal: React.FC = ({ onClick={() => createShareLink()} loading={isLoading} > - {t('storageShare.generate', 'Generate Link')} + {t("storageShare.generate", "Generate Link")} @@ -464,19 +442,19 @@ const ShareManagementModal: React.FC = ({ - {t('storageShare.sharedUsersTitle', 'Shared users')} + {t("storageShare.sharedUsersTitle", "Shared users")} { setShareUsername(event.currentTarget.value); setShowEmailWarning(false); }} onKeyDown={(event) => { - if (event.key === 'Enter') { + if (event.key === "Enter") { event.preventDefault(); void handleAddUser(); } @@ -488,31 +466,24 @@ const ShareManagementModal: React.FC = ({ onClick={() => handleAddUser()} disabled={!sharingEnabled || isLoading || !normalizedShareUsername || !!shareUsernameError} > - {t('storageShare.addUser', 'Add')} + {t("storageShare.addUser", "Add")} {showEmailWarning && ( - + {t( - 'storageShare.emailWarningBody', - 'This looks like an email address. If this person is not already a Stirling PDF user, they will not be able to access the file.' + "storageShare.emailWarningBody", + "This looks like an email address. If this person is not already a Stirling PDF user, they will not be able to access the file.", )} - - @@ -520,7 +491,7 @@ const ShareManagementModal: React.FC = ({ )} {sharedUsers.length === 0 ? ( - {t('storageShare.noSharedUsers', 'No users have access yet.')} + {t("storageShare.noSharedUsers", "No users have access yet.")} ) : ( @@ -528,24 +499,24 @@ const ShareManagementModal: React.FC = ({ {user.username} - {user.accessRole === 'commenter' && ( + {user.accessRole === "commenter" && ( - {t('storageShare.commenterHint', 'Commenting is coming soon.')} + {t("storageShare.commenterHint", "Commenting is coming soon.")} )} onChange(e.currentTarget.value)} - autoComplete={autoComplete} - className={styles.input} - disabled={disabled} - readOnly={readOnly} - aria-label={ariaLabel} - onFocus={onFocus} - style={{ - backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF', - color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382', - paddingRight: shouldShowClearButton ? '40px' : '12px', - paddingLeft: icon ? '40px' : '12px', - }} - {...props} - /> - {shouldShowClearButton && ( - - )} -
- ); -}); + return ( +
+ {icon && ( + + {icon} + + )} + onChange(e.currentTarget.value)} + autoComplete={autoComplete} + className={styles.input} + disabled={disabled} + readOnly={readOnly} + aria-label={ariaLabel} + onFocus={onFocus} + style={{ + backgroundColor: colorScheme === "dark" ? "#4B525A" : "#FFFFFF", + color: colorScheme === "dark" ? "#FFFFFF" : "#6B7382", + paddingRight: shouldShowClearButton ? "40px" : "12px", + paddingLeft: icon ? "40px" : "12px", + }} + {...props} + /> + {shouldShowClearButton && ( + + )} +
+ ); + }, +); -TextInput.displayName = 'TextInput'; +TextInput.displayName = "TextInput"; diff --git a/frontend/src/core/components/shared/ToolChain.tsx b/frontend/src/core/components/shared/ToolChain.tsx index c67d894273..7d2d45c6c2 100644 --- a/frontend/src/core/components/shared/ToolChain.tsx +++ b/frontend/src/core/components/shared/ToolChain.tsx @@ -3,63 +3,66 @@ * Used across FileListItem, FileDetails, and FileThumbnail for consistent display */ -import React from 'react'; -import { Text, Tooltip, Badge, Group } from '@mantine/core'; -import { ToolOperation } from '@app/types/file'; -import { useTranslation } from 'react-i18next'; -import { ToolId } from '@app/types/toolId'; +import React from "react"; +import { Text, Tooltip, Badge, Group } from "@mantine/core"; +import { ToolOperation } from "@app/types/file"; +import { useTranslation } from "react-i18next"; +import { ToolId } from "@app/types/toolId"; interface ToolChainProps { toolChain: ToolOperation[]; maxWidth?: string; - displayStyle?: 'text' | 'badges' | 'compact'; - size?: 'xs' | 'sm' | 'md'; + displayStyle?: "text" | "badges" | "compact"; + size?: "xs" | "sm" | "md"; color?: string; } const ToolChain: React.FC = ({ toolChain, - maxWidth = '100%', - displayStyle = 'text', - size = 'xs', - color = 'var(--mantine-color-blue-7)' + maxWidth = "100%", + displayStyle = "text", + size = "xs", + color = "var(--mantine-color-blue-7)", }) => { const { t } = useTranslation(); if (!toolChain || toolChain.length === 0) return null; - const toolIds = toolChain.map(tool => tool.toolId); + const toolIds = toolChain.map((tool) => tool.toolId); const getToolName = (toolId: ToolId) => { return t(`home.${toolId}.title`, toolId); }; // Create full tool chain for tooltip - const fullChainDisplay = displayStyle === 'badges' ? ( - - {toolChain.map((tool, index) => ( - - - {getToolName(tool.toolId)} - - {index < toolChain.length - 1 && ( - → - )} - - ))} - - ) : ( - {toolIds.map(getToolName).join(' → ')} - ); + const fullChainDisplay = + displayStyle === "badges" ? ( + + {toolChain.map((tool, index) => ( + + + {getToolName(tool.toolId)} + + {index < toolChain.length - 1 && ( + + → + + )} + + ))} + + ) : ( + {toolIds.map(getToolName).join(" → ")} + ); // Create truncated display based on available space const getTruncatedDisplay = () => { if (toolIds.length <= 2) { // Show all tools if 2 or fewer - return { text: toolIds.map(getToolName).join(' → '), isTruncated: false }; + return { text: toolIds.map(getToolName).join(" → "), isTruncated: false }; } else { // Show first tool ... last tool for longer chains return { - text: `${getToolName(toolIds[0])} → +${toolIds.length-2} → ${getToolName(toolIds[toolIds.length - 1])}`, + text: `${getToolName(toolIds[0])} → +${toolIds.length - 2} → ${getToolName(toolIds[toolIds.length - 1])}`, isTruncated: true, }; } @@ -68,7 +71,7 @@ const ToolChain: React.FC = ({ const { text: truncatedText, isTruncated } = getTruncatedDisplay(); // Compact style for very small spaces - if (displayStyle === 'compact') { + if (displayStyle === "compact") { const compactText = toolIds.length === 1 ? getToolName(toolIds[0]) : `${toolIds.length} tools`; const isCompactTruncated = toolIds.length > 1; @@ -78,11 +81,11 @@ const ToolChain: React.FC = ({ style={{ color, fontWeight: 500, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", maxWidth: `${maxWidth}`, - cursor: isCompactTruncated ? 'help' : 'default' + cursor: isCompactTruncated ? "help" : "default", }} > {compactText} @@ -93,15 +96,17 @@ const ToolChain: React.FC = ({ {compactElement} - ) : compactElement; + ) : ( + compactElement + ); } // Badge style for file details - if (displayStyle === 'badges') { + if (displayStyle === "badges") { const isBadgesTruncated = toolChain.length > 3; const badgesElement = ( -
+
{toolChain.slice(0, 3).map((tool, index) => ( @@ -109,13 +114,17 @@ const ToolChain: React.FC = ({ {getToolName(tool.toolId)} {index < Math.min(toolChain.length - 1, 2) && ( - → + + → + )} ))} {toolChain.length > 3 && ( <> - ... + + ... + {getToolName(toolChain[toolChain.length - 1].toolId)} @@ -126,10 +135,12 @@ const ToolChain: React.FC = ({ ); return isBadgesTruncated ? ( - + {badgesElement} - ) : badgesElement; + ) : ( + badgesElement + ); } // Text style (default) for file list items @@ -139,11 +150,11 @@ const ToolChain: React.FC = ({ style={{ color, fontWeight: 500, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", maxWidth: `${maxWidth}`, - cursor: isTruncated ? 'help' : 'default' + cursor: isTruncated ? "help" : "default", }} > {truncatedText} @@ -154,7 +165,9 @@ const ToolChain: React.FC = ({ {textElement} - ) : textElement; + ) : ( + textElement + ); }; export default ToolChain; diff --git a/frontend/src/core/components/shared/ToolIcon.tsx b/frontend/src/core/components/shared/ToolIcon.tsx index 75ab249ba7..d0a1c82b9b 100644 --- a/frontend/src/core/components/shared/ToolIcon.tsx +++ b/frontend/src/core/components/shared/ToolIcon.tsx @@ -15,7 +15,7 @@ export const ToolIcon: React.FC = ({ icon, opacity = 1, color = "var(--tools-text-and-icon-color)", - marginRight = "0.5rem" + marginRight = "0.5rem", }) => { return (
= ({ marginRight, transform: "scale(0.8)", transformOrigin: "center", - opacity + opacity, }} > {icon} diff --git a/frontend/src/core/components/shared/Tooltip.tsx b/frontend/src/core/components/shared/Tooltip.tsx index 2580b3530a..8757e56bfa 100644 --- a/frontend/src/core/components/shared/Tooltip.tsx +++ b/frontend/src/core/components/shared/Tooltip.tsx @@ -1,18 +1,18 @@ -import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { createPortal } from 'react-dom'; -import LocalIcon from '@app/components/shared/LocalIcon'; -import { addEventListenerWithCleanup } from '@app/utils/genericUtils'; -import { useTooltipPosition } from '@app/hooks/useTooltipPosition'; -import { TooltipTip } from '@app/types/tips'; -import { TooltipContent } from '@app/components/shared/tooltip/TooltipContent'; -import { useSidebarContext } from '@app/contexts/SidebarContext'; -import { useLogoAssets } from '@app/hooks/useLogoAssets'; -import styles from '@app/components/shared/tooltip/Tooltip.module.css'; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; +import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"; +import { createPortal } from "react-dom"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { addEventListenerWithCleanup } from "@app/utils/genericUtils"; +import { useTooltipPosition } from "@app/hooks/useTooltipPosition"; +import { TooltipTip } from "@app/types/tips"; +import { TooltipContent } from "@app/components/shared/tooltip/TooltipContent"; +import { useSidebarContext } from "@app/contexts/SidebarContext"; +import { useLogoAssets } from "@app/hooks/useLogoAssets"; +import styles from "@app/components/shared/tooltip/Tooltip.module.css"; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; export interface TooltipProps { sidebarTooltip?: boolean; - position?: 'right' | 'left' | 'top' | 'bottom'; + position?: "right" | "left" | "top" | "bottom"; content?: React.ReactNode; tips?: TooltipTip[]; children: React.ReactElement; @@ -73,8 +73,7 @@ export const Tooltip: React.FC = ({ const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`); // Runtime guard: some browsers may surface non-Node EventTargets for relatedTarget/target - const isDomNode = (value: unknown): value is Node => - typeof Node !== 'undefined' && value instanceof Node; + const isDomNode = (value: unknown): value is Node => typeof Node !== "undefined" && value instanceof Node; const clearTimers = useCallback(() => { if (openTimeoutRef.current) { @@ -92,14 +91,14 @@ export const Tooltip: React.FC = ({ const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled; const allowAutoClose = !manualCloseOnly; - const resolvedPosition: NonNullable = useMemo(() => { - const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr'; - const isRTL = htmlDir === 'rtl'; - const base = position ?? 'right'; - if (!isRTL) return base as NonNullable; - if (base === 'left') return 'right'; - if (base === 'right') return 'left'; - return base as NonNullable; + const resolvedPosition: NonNullable = useMemo(() => { + const htmlDir = typeof document !== "undefined" ? document.documentElement.dir : "ltr"; + const isRTL = htmlDir === "rtl"; + const base = position ?? "right"; + if (!isRTL) return base as NonNullable; + if (base === "left") return "right"; + if (base === "right") return "left"; + return base as NonNullable; }, [position]); const setOpen = useCallback( @@ -109,7 +108,7 @@ export const Tooltip: React.FC = ({ else setInternalOpen(newOpen); if (!newOpen) setIsPinned(false); }, - [isControlled, onOpenChange, open] + [isControlled, onOpenChange, open], ); const { coords, positionReady } = useTooltipPosition({ @@ -146,13 +145,13 @@ export const Tooltip: React.FC = ({ setOpen(false); } }, - [isPinned, closeOnOutside, setOpen, allowAutoClose] + [isPinned, closeOnOutside, setOpen, allowAutoClose], ); useEffect(() => { // Attach global click when open (so hover tooltips can also close on outside if desired) if (open || isPinned) { - return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener); + return addEventListenerWithCleanup(document, "click", handleDocumentClick as EventListener); } }, [open, isPinned, handleDocumentClick]); @@ -160,11 +159,11 @@ export const Tooltip: React.FC = ({ const arrowClass = useMemo(() => { if (sidebarTooltip) return null; - const map: Record, string> = { - top: 'tooltip-arrow-bottom', - bottom: 'tooltip-arrow-top', - left: 'tooltip-arrow-left', - right: 'tooltip-arrow-right', + const map: Record, string> = { + top: "tooltip-arrow-bottom", + bottom: "tooltip-arrow-top", + left: "tooltip-arrow-left", + right: "tooltip-arrow-right", }; return map[resolvedPosition] || map.right; }, [resolvedPosition, sidebarTooltip]); @@ -173,8 +172,8 @@ export const Tooltip: React.FC = ({ (key: string) => styles[key as keyof typeof styles] || styles[key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()) as keyof typeof styles] || - '', - [] + "", + [], ); // === Trigger handlers === @@ -189,7 +188,7 @@ export const Tooltip: React.FC = ({ if (!isPinned && !disabled) openWithDelay(); (children.props as any)?.onPointerEnter?.(e); }, - [isPinned, openWithDelay, children.props, disabled] + [isPinned, openWithDelay, children.props, disabled], ); const handlePointerLeave = useCallback( @@ -198,7 +197,6 @@ export const Tooltip: React.FC = ({ // Moving into the tooltip → keep open if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) { - (children.props as any)?.onPointerLeave?.(e); return; } @@ -213,7 +211,7 @@ export const Tooltip: React.FC = ({ if (allowAutoClose && !isPinned) setOpen(false); (children.props as any)?.onPointerLeave?.(e); }, - [clearTimers, isPinned, setOpen, children.props, allowAutoClose] + [clearTimers, isPinned, setOpen, children.props, allowAutoClose], ); const handleMouseDown = useCallback( @@ -221,7 +219,7 @@ export const Tooltip: React.FC = ({ clickPendingRef.current = true; (children.props as any)?.onMouseDown?.(e); }, - [children.props] + [children.props], ); const handleMouseUp = useCallback( @@ -230,7 +228,7 @@ export const Tooltip: React.FC = ({ queueMicrotask(() => (clickPendingRef.current = false)); (children.props as any)?.onMouseUp?.(e); }, - [children.props] + [children.props], ); const handleClick = useCallback( @@ -247,7 +245,7 @@ export const Tooltip: React.FC = ({ clickPendingRef.current = false; (children.props as any)?.onClick?.(e); }, - [clearTimers, pinOnClick, open, setOpen, children.props] + [clearTimers, pinOnClick, open, setOpen, children.props], ); // Keyboard / focus accessibility @@ -256,7 +254,7 @@ export const Tooltip: React.FC = ({ if (!isPinned && !disabled && openOnFocus) openWithDelay(); (children.props as any)?.onFocus?.(e); }, - [isPinned, openWithDelay, children.props, disabled, openOnFocus] + [isPinned, openWithDelay, children.props, disabled, openOnFocus], ); const handleBlur = useCallback( @@ -270,13 +268,16 @@ export const Tooltip: React.FC = ({ if (allowAutoClose && !isPinned) setOpen(false); (children.props as any)?.onBlur?.(e); }, - [isPinned, setOpen, children.props, allowAutoClose, clearTimers] + [isPinned, setOpen, children.props, allowAutoClose, clearTimers], ); - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (manualCloseOnly) return; - if (e.key === 'Escape') setOpen(false); - }, [setOpen, manualCloseOnly]); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (manualCloseOnly) return; + if (e.key === "Escape") setOpen(false); + }, + [setOpen, manualCloseOnly], + ); // Keep open while pointer is over the tooltip; close when leaving it (if not pinned) const handleTooltipPointerEnter = useCallback(() => { @@ -289,7 +290,7 @@ export const Tooltip: React.FC = ({ if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return; if (allowAutoClose && !isPinned) setOpen(false); }, - [isPinned, setOpen, allowAutoClose] + [isPinned, setOpen, allowAutoClose], ); // Enhance child with handlers and ref @@ -297,10 +298,10 @@ export const Tooltip: React.FC = ({ ref: (node: HTMLElement | null) => { triggerRef.current = node || null; const originalRef = (children as any).ref; - if (typeof originalRef === 'function') originalRef(node); - else if (originalRef && typeof originalRef === 'object') (originalRef as any).current = node; + if (typeof originalRef === "function") originalRef(node); + else if (originalRef && typeof originalRef === "object") (originalRef as any).current = node; }, - 'aria-describedby': open ? tooltipIdRef.current : undefined, + "aria-describedby": open ? tooltipIdRef.current : undefined, onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave, onMouseDown: handleMouseDown, @@ -323,23 +324,30 @@ export const Tooltip: React.FC = ({ onPointerEnter={handleTooltipPointerEnter} onPointerLeave={handleTooltipPointerLeave} style={{ - position: 'fixed', + position: "fixed", top: coords.top, left: coords.left, - width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined), + width: maxWidth !== undefined ? maxWidth : sidebarTooltip ? ("25rem" as const) : undefined, minWidth, zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE, - visibility: positionReady ? 'visible' : 'hidden', + visibility: positionReady ? "visible" : "hidden", opacity: positionReady ? 1 : 0, - color: 'var(--text-primary)', + color: "var(--text-primary)", ...containerStyle, }} - className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`} - onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined} + className={`${styles["tooltip-container"]} ${isPinned ? styles.pinned : ""}`} + onClick={ + pinOnClick + ? (e) => { + e.stopPropagation(); + setIsPinned(true); + } + : undefined + } > {shouldShowCloseButton && ( @@ -254,7 +246,7 @@ const UpdateModal: React.FC = ({ - {t('update.loadingDetailedInfo', 'Loading detailed information...')} + {t("update.loadingDetailedInfo", "Loading detailed information...")}
@@ -262,10 +254,10 @@ const UpdateModal: React.FC = ({ - {t('update.availableUpdates', 'Available Updates')} + {t("update.availableUpdates", "Available Updates")} - {fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? 'version' : 'versions'} + {fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? "version" : "versions"} @@ -275,9 +267,9 @@ const UpdateModal: React.FC = ({ = ({ align="center" p="md" style={{ - cursor: 'pointer', - background: isExpanded ? 'var(--mantine-color-gray-0)' : 'transparent', - transition: 'background 0.15s ease', + cursor: "pointer", + background: isExpanded ? "var(--mantine-color-gray-0)" : "transparent", + transition: "background 0.15s ease", }} onClick={() => toggleVersion(index)} > - {t('update.version', 'Version')} + {t("update.version", "Version")} {version.version} @@ -314,18 +306,18 @@ const UpdateModal: React.FC = ({ onClick={(e) => e.stopPropagation()} rightSection={} > - {t('update.releaseNotes', 'Release Notes')} + {t("update.releaseNotes", "Release Notes")} {isExpanded ? ( - + ) : ( - + )} - + @@ -339,21 +331,23 @@ const UpdateModal: React.FC = ({ {version.compatibility.breaking_changes && ( - + - {t('update.breakingChanges', 'Breaking Changes')} + {t("update.breakingChanges", "Breaking Changes")} {version.compatibility.breaking_description || - t('update.breakingChangesDefault', 'This version contains breaking changes.')} + t("update.breakingChangesDefault", "This version contains breaking changes.")} {version.compatibility.migration_guide_url && ( )} @@ -384,7 +378,7 @@ const UpdateModal: React.FC = ({ {downloadUrl && ( )} diff --git a/frontend/src/core/components/shared/UploadToServerModal.tsx b/frontend/src/core/components/shared/UploadToServerModal.tsx index ecf424e60b..5bab2d8efa 100644 --- a/frontend/src/core/components/shared/UploadToServerModal.tsx +++ b/frontend/src/core/components/shared/UploadToServerModal.tsx @@ -1,15 +1,15 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Modal, Stack, Text, Button, Group, Alert } from '@mantine/core'; -import CloudUploadIcon from '@mui/icons-material/CloudUpload'; -import { useTranslation } from 'react-i18next'; +import React, { useCallback, useEffect, useState } from "react"; +import { Modal, Stack, Text, Button, Group, Alert } from "@mantine/core"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import { useTranslation } from "react-i18next"; -import { alert } from '@app/components/toast'; -import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; -import type { StirlingFileStub } from '@app/types/fileContext'; -import { uploadHistoryChain } from '@app/services/serverStorageUpload'; -import { fileStorage } from '@app/services/fileStorage'; -import { useFileActions } from '@app/contexts/FileContext'; -import type { FileId } from '@app/types/file'; +import { alert } from "@app/components/toast"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import { uploadHistoryChain } from "@app/services/serverStorageUpload"; +import { fileStorage } from "@app/services/fileStorage"; +import { useFileActions } from "@app/contexts/FileContext"; +import type { FileId } from "@app/types/file"; interface UploadToServerModalProps { opened: boolean; @@ -18,12 +18,7 @@ interface UploadToServerModalProps { onUploaded?: () => Promise | void; } -const UploadToServerModal: React.FC = ({ - opened, - onClose, - file, - onUploaded, -}) => { +const UploadToServerModal: React.FC = ({ opened, onClose, file, onUploaded }) => { const { t } = useTranslation(); const { actions } = useFileActions(); const [isUploading, setIsUploading] = useState(false); @@ -43,10 +38,7 @@ const UploadToServerModal: React.FC = ({ try { const originalFileId = (file.originalFileId || file.id) as FileId; const remoteId = file.remoteStorageId; - const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChain( - originalFileId, - remoteId - ); + const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChain(originalFileId, remoteId); for (const stub of chain) { actions.updateStirlingFileStub(stub.id, { @@ -62,8 +54,8 @@ const UploadToServerModal: React.FC = ({ } alert({ - alertType: 'success', - title: t('storageUpload.success', 'Uploaded to server'), + alertType: "success", + title: t("storageUpload.success", "Uploaded to server"), expandable: false, durationMs: 3000, }); @@ -72,10 +64,8 @@ const UploadToServerModal: React.FC = ({ } onClose(); } catch (error) { - console.error('Failed to upload file to server:', error); - setErrorMessage( - t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.') - ); + console.error("Failed to upload file to server:", error); + setErrorMessage(t("storageUpload.failure", "Upload failed. Please check your login and storage settings.")); } finally { setIsUploading(false); } @@ -86,44 +76,34 @@ const UploadToServerModal: React.FC = ({ opened={opened} onClose={onClose} centered - title={t('storageUpload.title', 'Upload to Server')} + title={t("storageUpload.title", "Upload to Server")} zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL} > - {t( - 'storageUpload.description', - 'This uploads the current file to server storage for your own access.' - )} + {t("storageUpload.description", "This uploads the current file to server storage for your own access.")} - {t('storageUpload.fileLabel', 'File')}: {file.name} + {t("storageUpload.fileLabel", "File")}: {file.name} - {t( - 'storageUpload.hint', - 'Public links and access modes are controlled by your server settings.' - )} + {t("storageUpload.hint", "Public links and access modes are controlled by your server settings.")} {errorMessage && ( - + {errorMessage} )} - diff --git a/frontend/src/core/components/shared/UserSelector.tsx b/frontend/src/core/components/shared/UserSelector.tsx index 22c99ec61d..5e292bacda 100644 --- a/frontend/src/core/components/shared/UserSelector.tsx +++ b/frontend/src/core/components/shared/UserSelector.tsx @@ -1,25 +1,25 @@ -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { MultiSelect, Loader, Text, Button, Stack } from '@mantine/core'; -import { useNavigate } from 'react-router-dom'; -import { alert } from '@app/components/toast'; -import { UserSummary } from '@app/types/signingSession'; -import apiClient from '@app/services/apiClient'; -import { useAuth } from '@app/auth/UseSession'; -import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { MultiSelect, Loader, Text, Button, Stack } from "@mantine/core"; +import { useNavigate } from "react-router-dom"; +import { alert } from "@app/components/toast"; +import { UserSummary } from "@app/types/signingSession"; +import apiClient from "@app/services/apiClient"; +import { useAuth } from "@app/auth/UseSession"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; interface UserSelectorProps { value: number[]; onChange: (userIds: number[]) => void; placeholder?: string; - size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + size?: "xs" | "sm" | "md" | "lg" | "xl"; disabled?: boolean; } type SelectItem = { value: string; label: string }; type GroupedData = { group: string; items: SelectItem[] }; -const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = false }: UserSelectorProps) => { +const UserSelector = ({ value, onChange, placeholder, size = "sm", disabled = false }: UserSelectorProps) => { const { t } = useTranslation(); const { user } = useAuth(); const navigate = useNavigate(); @@ -30,8 +30,8 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa useEffect(() => { const fetchUsers = async () => { try { - const response = await apiClient.get('/api/v1/user/users'); - console.log('Users API response:', response.data); + const response = await apiClient.get("/api/v1/user/users"); + console.log("Users API response:", response.data); const fetchedUsers = response.data || []; // Process selectData inside useEffect - group by team @@ -41,16 +41,15 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa fetchedUsers .filter((u: UserSummary) => u && u.userId && u.username) .filter((u: UserSummary) => u.userId !== currentUserId) // Exclude current user - .filter((u: UserSummary) => u.teamName?.toLowerCase() !== 'internal') // Exclude internal users + .filter((u: UserSummary) => u.teamName?.toLowerCase() !== "internal") // Exclude internal users .forEach((user: UserSummary) => { - const teamName = user.teamName || t('certSign.collab.userSelector.noTeam', 'No Team'); + const teamName = user.teamName || t("certSign.collab.userSelector.noTeam", "No Team"); if (!usersByTeam[teamName]) { usersByTeam[teamName] = []; } - const displayName = user.displayName || user.username || 'Unknown'; - const username = user.username || 'unknown'; - const label = - displayName !== username ? `${displayName} (@${username})` : displayName; + const displayName = user.displayName || user.username || "Unknown"; + const username = user.username || "unknown"; + const label = displayName !== username ? `${displayName} (@${username})` : displayName; usersByTeam[teamName].push({ value: String(user.userId), label, @@ -63,14 +62,14 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa items: items.sort((a, b) => a.label.localeCompare(b.label)), })); - console.log('Processed selectData:', processed); + console.log("Processed selectData:", processed); setSelectData(processed); } catch (error) { - console.error('Failed to load users:', error); + console.error("Failed to load users:", error); alert({ - alertType: 'error', - title: t('common.error'), - body: t('certSign.collab.userSelector.loadError', 'Failed to load users'), + alertType: "error", + title: t("common.error"), + body: t("certSign.collab.userSelector.loadError", "Failed to load users"), }); } finally { setLoading(false); @@ -83,8 +82,8 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa // Process stringValue when value prop changes useEffect(() => { const safeValue = Array.isArray(value) ? value : []; - const result = safeValue.map((id) => (id != null ? id.toString() : '')).filter(Boolean); - console.log('stringValue for MultiSelect:', result); + const result = safeValue.map((id) => (id != null ? id.toString() : "")).filter(Boolean); + console.log("stringValue for MultiSelect:", result); setStringValue(result); }, [value]); @@ -97,10 +96,10 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa return ( - {t('certSign.collab.userSelector.noUsers', 'No other users found.')} + {t("certSign.collab.userSelector.noUsers", "No other users found.")} - ); @@ -111,12 +110,10 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa data={selectData} value={stringValue} onChange={(selectedIds) => { - const parsedIds = selectedIds - .map((id) => parseInt(id, 10)) - .filter((id) => !isNaN(id)); + const parsedIds = selectedIds.map((id) => parseInt(id, 10)).filter((id) => !isNaN(id)); onChange(parsedIds); }} - placeholder={placeholder || t('certSign.collab.userSelector.placeholder', 'Select users...')} + placeholder={placeholder || t("certSign.collab.userSelector.placeholder", "Select users...")} searchable clearable size={size} diff --git a/frontend/src/core/components/shared/ZipWarningModal.tsx b/frontend/src/core/components/shared/ZipWarningModal.tsx index 909cf1b31b..7a6e32c971 100644 --- a/frontend/src/core/components/shared/ZipWarningModal.tsx +++ b/frontend/src/core/components/shared/ZipWarningModal.tsx @@ -15,9 +15,9 @@ interface ZipWarningModalProps { const WARNING_ICON_STYLE: CSSProperties = { fontSize: 36, - display: 'block', - margin: '0 auto 8px', - color: 'var(--mantine-color-blue-6)' + display: "block", + margin: "0 auto 8px", + color: "var(--mantine-color-blue-6)", }; const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName }: ZipWarningModalProps) => { @@ -41,7 +41,7 @@ const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName } {t("zipWarning.message", { count: fileCount, - defaultValue: "This ZIP contains {{count}} files. Extract anyway?" + defaultValue: "This ZIP contains {{count}} files. Extract anyway?", })} diff --git a/frontend/src/core/components/shared/config/LoginRequiredBanner.tsx b/frontend/src/core/components/shared/config/LoginRequiredBanner.tsx index f16e38f4ea..d540bc989f 100644 --- a/frontend/src/core/components/shared/config/LoginRequiredBanner.tsx +++ b/frontend/src/core/components/shared/config/LoginRequiredBanner.tsx @@ -1,6 +1,6 @@ -import { Alert, Text } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import LocalIcon from '@app/components/shared/LocalIcon'; +import { Alert, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; interface LoginRequiredBannerProps { show: boolean; @@ -18,20 +18,26 @@ export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps) return ( } - title={t('admin.settings.loginDisabled.title', 'Login Mode Required')} + title={t("admin.settings.loginDisabled.title", "Login Mode Required")} color="blue" variant="light" styles={{ root: { - borderLeft: '4px solid var(--mantine-color-blue-6)' - } + borderLeft: "4px solid var(--mantine-color-blue-6)", + }, }} > - {t('admin.settings.loginDisabled.message', 'Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.')} + {t( + "admin.settings.loginDisabled.message", + "Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.", + )} - {t('admin.settings.loginDisabled.readOnly', 'The settings below show example values for reference. Enable login mode to view and edit actual configuration.')} + {t( + "admin.settings.loginDisabled.readOnly", + "The settings below show example values for reference. Enable login mode to view and edit actual configuration.", + )} ); diff --git a/frontend/src/core/components/shared/config/OverviewHeader.tsx b/frontend/src/core/components/shared/config/OverviewHeader.tsx index 7be820620a..fe694d29b8 100644 --- a/frontend/src/core/components/shared/config/OverviewHeader.tsx +++ b/frontend/src/core/components/shared/config/OverviewHeader.tsx @@ -1,14 +1,16 @@ -import { Text } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; +import { Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; export function OverviewHeader() { const { t } = useTranslation(); return (
- {t('config.overview.title', 'Application Configuration')} + + {t("config.overview.title", "Application Configuration")} + - {t('config.overview.description', 'Current application settings and configuration details.')} + {t("config.overview.description", "Current application settings and configuration details.")}
); diff --git a/frontend/src/core/components/shared/config/PendingBadge.tsx b/frontend/src/core/components/shared/config/PendingBadge.tsx index cdb3306f80..625c2499e2 100644 --- a/frontend/src/core/components/shared/config/PendingBadge.tsx +++ b/frontend/src/core/components/shared/config/PendingBadge.tsx @@ -1,22 +1,22 @@ -import { Badge } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; +import { Badge } from "@mantine/core"; +import { useTranslation } from "react-i18next"; interface PendingBadgeProps { show: boolean; - size?: 'xs' | 'sm' | 'md' | 'lg'; + size?: "xs" | "sm" | "md" | "lg"; } /** * Badge to show when a setting has been saved but requires restart to take effect. */ -export default function PendingBadge({ show, size = 'xs' }: PendingBadgeProps) { +export default function PendingBadge({ show, size = "xs" }: PendingBadgeProps) { const { t } = useTranslation(); if (!show) return null; return ( - {t('admin.settings.restartRequired', 'Restart Required')} + {t("admin.settings.restartRequired", "Restart Required")} ); } diff --git a/frontend/src/core/components/shared/config/RestartConfirmationModal.tsx b/frontend/src/core/components/shared/config/RestartConfirmationModal.tsx index b97b17a0c6..9f8c75cee3 100644 --- a/frontend/src/core/components/shared/config/RestartConfirmationModal.tsx +++ b/frontend/src/core/components/shared/config/RestartConfirmationModal.tsx @@ -1,8 +1,8 @@ -import { Modal, Text, Group, Button, Stack } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import ScheduleIcon from '@mui/icons-material/Schedule'; -import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +import { Modal, Text, Group, Button, Stack } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import ScheduleIcon from "@mui/icons-material/Schedule"; +import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; interface RestartConfirmationModalProps { opened: boolean; @@ -10,11 +10,7 @@ interface RestartConfirmationModalProps { onRestart: () => void; } -export default function RestartConfirmationModal({ - opened, - onClose, - onRestart, -}: RestartConfirmationModalProps) { +export default function RestartConfirmationModal({ opened, onClose, onRestart }: RestartConfirmationModalProps) { const { t } = useTranslation(); return ( @@ -23,7 +19,7 @@ export default function RestartConfirmationModal({ onClose={onClose} title={ - {t('admin.settings.restart.title', 'Restart Required')} + {t("admin.settings.restart.title", "Restart Required")} } centered @@ -34,32 +30,21 @@ export default function RestartConfirmationModal({ {t( - 'admin.settings.restart.message', - 'Settings have been saved successfully. A server restart is required for the changes to take effect.' + "admin.settings.restart.message", + "Settings have been saved successfully. A server restart is required for the changes to take effect.", )} - {t( - 'admin.settings.restart.question', - 'Would you like to restart the server now or later?' - )} + {t("admin.settings.restart.question", "Would you like to restart the server now or later?")} - - diff --git a/frontend/src/core/components/shared/config/SettingsSearchBar.tsx b/frontend/src/core/components/shared/config/SettingsSearchBar.tsx index cbbbd97076..b08a8b6184 100644 --- a/frontend/src/core/components/shared/config/SettingsSearchBar.tsx +++ b/frontend/src/core/components/shared/config/SettingsSearchBar.tsx @@ -1,10 +1,10 @@ -import React, { useMemo, useState, useCallback } from 'react'; -import { Select, Text } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import LocalIcon from '@app/components/shared/LocalIcon'; -import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types'; -import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; -import type { ConfigNavSection, ConfigNavItem } from '@app/components/shared/config/configNavSections'; +import React, { useMemo, useState, useCallback } from "react"; +import { Select, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types"; +import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; +import type { ConfigNavSection, ConfigNavItem } from "@app/components/shared/config/configNavSections"; interface SettingsSearchBarProps { configNavSections: ConfigNavSection[]; @@ -22,35 +22,35 @@ interface SettingsSearchOption { } const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial> = { - general: ['settings.general'], - hotkeys: ['settings.hotkeys'], - account: ['account'], - people: ['settings.workspace'], - teams: ['settings.workspace', 'settings.team'], - 'api-keys': ['settings.developer'], - connectionMode: ['settings.connection'], - planBilling: ['settings.planBilling'], - adminGeneral: ['admin.settings.general'], - adminFeatures: ['admin.settings.features'], - adminEndpoints: ['admin.settings.endpoints'], - adminDatabase: ['admin.settings.database'], - adminAdvanced: ['admin.settings.advanced'], - adminSecurity: ['admin.settings.security'], + general: ["settings.general"], + hotkeys: ["settings.hotkeys"], + account: ["account"], + people: ["settings.workspace"], + teams: ["settings.workspace", "settings.team"], + "api-keys": ["settings.developer"], + connectionMode: ["settings.connection"], + planBilling: ["settings.planBilling"], + adminGeneral: ["admin.settings.general"], + adminFeatures: ["admin.settings.features"], + adminEndpoints: ["admin.settings.endpoints"], + adminDatabase: ["admin.settings.database"], + adminAdvanced: ["admin.settings.advanced"], + adminSecurity: ["admin.settings.security"], adminConnections: [ - 'admin.settings.connections', - 'admin.settings.mail', - 'admin.settings.security', - 'admin.settings.telegram', - 'admin.settings.premium', - 'admin.settings.general', - 'settings.securityAuth', - 'settings.connection', + "admin.settings.connections", + "admin.settings.mail", + "admin.settings.security", + "admin.settings.telegram", + "admin.settings.premium", + "admin.settings.general", + "settings.securityAuth", + "settings.connection", ], - adminPlan: ['settings.planBilling', 'admin.settings.premium', 'settings.licensingAnalytics'], - adminAudit: ['settings.licensingAnalytics'], - adminUsage: ['settings.licensingAnalytics'], - adminLegal: ['admin.settings.legal'], - adminPrivacy: ['admin.settings.privacy'], + adminPlan: ["settings.planBilling", "admin.settings.premium", "settings.licensingAnalytics"], + adminAudit: ["settings.licensingAnalytics"], + adminUsage: ["settings.licensingAnalytics"], + adminLegal: ["admin.settings.legal"], + adminPrivacy: ["admin.settings.privacy"], }; const getTranslationPrefixesForNavKey = (key: string): string[] => { @@ -58,8 +58,8 @@ const getTranslationPrefixesForNavKey = (key: string): string[] => { const inferredPrefixes: string[] = []; - if (key.startsWith('admin')) { - const adminSuffix = key.replace(/^admin/, ''); + if (key.startsWith("admin")) { + const adminSuffix = key.replace(/^admin/, ""); const normalizedAdminSuffix = adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1); inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`); } else { @@ -70,7 +70,7 @@ const getTranslationPrefixesForNavKey = (key: string): string[] => { }; const flattenTranslationStrings = (value: unknown): string[] => { - if (typeof value === 'string') { + if (typeof value === "string") { const trimmed = value.trim(); return trimmed ? [trimmed] : []; } @@ -79,7 +79,7 @@ const flattenTranslationStrings = (value: unknown): string[] => { return value.flatMap(flattenTranslationStrings); } - if (value && typeof value === 'object') { + if (value && typeof value === "object") { return Object.values(value as Record).flatMap(flattenTranslationStrings); } @@ -102,19 +102,15 @@ const buildMatchSnippet = (text: string, query: string): string => { const snippet = text.slice(start, end); if (snippet.length <= maxLength) { - return `${start > 0 ? '…' : ''}${snippet}${end < text.length ? '…' : ''}`; + return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`; } - return `${start > 0 ? '…' : ''}${snippet.slice(0, maxLength)}${end < text.length ? '…' : ''}`; + return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`; }; -export const SettingsSearchBar: React.FC = ({ - configNavSections, - onNavigate, - isMobile, -}) => { +export const SettingsSearchBar: React.FC = ({ configNavSections, onNavigate, isMobile }) => { const { t } = useTranslation(); - const [searchValue, setSearchValue] = useState(''); + const [searchValue, setSearchValue] = useState(""); // Build a global index from every accessible settings tab in the modal navigation. // This does not render section components, so API calls still happen only when a tab is opened. @@ -125,16 +121,11 @@ export const SettingsSearchBar: React.FC = ({ .map((item: ConfigNavItem) => { const translationPrefixes = getTranslationPrefixesForNavKey(item.key); const translationContent = translationPrefixes.flatMap((prefix) => - flattenTranslationStrings(t(prefix, { returnObjects: true, defaultValue: {} } as any)) + flattenTranslationStrings(t(prefix, { returnObjects: true, defaultValue: {} } as any)), ); const searchableContent = Array.from( - new Set([ - item.label, - section.title, - `/settings/${item.key}`, - ...translationContent, - ]) + new Set([item.label, section.title, `/settings/${item.key}`, ...translationContent]), ); return { @@ -144,7 +135,7 @@ export const SettingsSearchBar: React.FC = ({ destinationPath: `/settings/${item.key}`, searchableContent, }; - }) + }), ); }, [configNavSections, t]); @@ -157,9 +148,7 @@ export const SettingsSearchBar: React.FC = ({ const normalizedQuery = query.toLocaleLowerCase(); return searchableSections.reduce((accumulator, option) => { - const matchedEntry = option.searchableContent.find((entry) => - entry.toLocaleLowerCase().includes(normalizedQuery) - ); + const matchedEntry = option.searchableContent.find((entry) => entry.toLocaleLowerCase().includes(normalizedQuery)); if (!matchedEntry) { return accumulator; @@ -174,12 +163,15 @@ export const SettingsSearchBar: React.FC = ({ }, []); }, [searchValue, searchableSections]); - const handleSearchNavigation = useCallback(async (value: string | null) => { - if (!value) return; - if (!VALID_NAV_KEYS.includes(value as NavKey)) return; - await onNavigate(value as NavKey); - setSearchValue(''); - }, [onNavigate]); + const handleSearchNavigation = useCallback( + async (value: string | null) => { + if (!value) return; + if (!VALID_NAV_KEYS.includes(value as NavKey)) return; + await onNavigate(value as NavKey); + setSearchValue(""); + }, + [onNavigate], + ); return (