mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Add frontend autoformatting and set CI to require formatted code for all languages (#6052)
# Description of Changes Changes the strategy for autoformatting to reject PRs if they are not formatted correctly instead of allowing them to merge and then spawning a new PR to fix the formatting. The old strategy just caused more work for us because we'd have to manually approve the followup PR and get it merged, which required 2 reviewers so in practice it rarely got done and just meant everyone's PRs ended up containing reformatting for unrelated files, which makes code review unnecessarily difficult. If the PR's code is not formatted correctly after this PR, a comment will be added automatically to tell the author how to run the formatter script to fix their code so it can go in. This also enables autoformatting for the frontend code, using Prettier. I've enabled it for pretty much everything in the frontend folder, other than 3rd party files and files it doesn't make sense for. I also excluded Markdown because it sounds likely to be more annoying to have to autoformat the Markdown in the frontend folder but nowhere else. Open to changing this though if people disagree. > [!note] > > Advice to reviewers: The first commit contains all of the actual logic I've introduced (CI changes, Prettier config, etc.) > The second commit is just the reformatting of the entire frontend folder. > The first commit needs proper review, the second one just give it a spot-check that it's doing what you'd expect.
This commit is contained in:
@@ -50,6 +50,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
security-events: write
|
security-events: write
|
||||||
|
pull-requests: write
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -84,6 +85,60 @@ jobs:
|
|||||||
gradle-version: 9.3.1
|
gradle-version: 9.3.1
|
||||||
cache-disabled: true
|
cache-disabled: true
|
||||||
|
|
||||||
|
- name: Check Java formatting (Spotless)
|
||||||
|
if: matrix.jdk-version == 25 && matrix.spring-security == false
|
||||||
|
id: spotless-check
|
||||||
|
run: ./gradlew spotlessCheck
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||||
|
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||||
|
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||||
|
|
||||||
|
- name: Comment on Java formatting failure
|
||||||
|
if: steps.spotless-check.outcome == 'failure'
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const marker = '<!-- java-formatting-check -->';
|
||||||
|
const body = [
|
||||||
|
marker,
|
||||||
|
'### Java Formatting Check Failed',
|
||||||
|
'',
|
||||||
|
'Your code has formatting issues. Run the following command to fix them:',
|
||||||
|
'',
|
||||||
|
'```bash',
|
||||||
|
'./gradlew spotlessApply',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'Then commit and push the changes.',
|
||||||
|
].join('\n');
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
const existing = comments.find(c => c.body.includes(marker));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Fail if Java formatting issues found
|
||||||
|
if: steps.spotless-check.outcome == 'failure'
|
||||||
|
run: exit 1
|
||||||
|
|
||||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||||
run: ./gradlew build -PnoSpotless
|
run: ./gradlew build -PnoSpotless
|
||||||
env:
|
env:
|
||||||
@@ -187,6 +242,9 @@ jobs:
|
|||||||
if: needs.files-changed.outputs.frontend == 'true'
|
if: needs.files-changed.outputs.frontend == 'true'
|
||||||
needs: files-changed
|
needs: files-changed
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||||
@@ -202,6 +260,52 @@ jobs:
|
|||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: cd frontend && npm ci
|
run: cd frontend && npm ci
|
||||||
|
- name: Check TypeScript formatting (Prettier)
|
||||||
|
id: prettier-check
|
||||||
|
run: cd frontend && npm run format:check
|
||||||
|
continue-on-error: true
|
||||||
|
- name: Comment on TypeScript formatting failure
|
||||||
|
if: steps.prettier-check.outcome == 'failure'
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const marker = '<!-- typescript-formatting-check -->';
|
||||||
|
const body = [
|
||||||
|
marker,
|
||||||
|
'### TypeScript Formatting Check Failed',
|
||||||
|
'',
|
||||||
|
'Your code has formatting issues. Run the following command to fix them:',
|
||||||
|
'',
|
||||||
|
'```bash',
|
||||||
|
'cd frontend && npm run fix',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'Then commit and push the changes.',
|
||||||
|
].join('\n');
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
const existing = comments.find(c => c.body.includes(marker));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
- name: Fail if TypeScript formatting issues found
|
||||||
|
if: steps.prettier-check.outcome == 'failure'
|
||||||
|
run: exit 1
|
||||||
- name: Type-check frontend
|
- name: Type-check frontend
|
||||||
run: cd frontend && npm run prep && npm run typecheck:all
|
run: cd frontend && npm run prep && npm run typecheck:all
|
||||||
- name: Lint frontend
|
- name: Lint frontend
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Pre-commit
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
@@ -16,9 +16,6 @@ jobs:
|
|||||||
# Prevents sdist builds → no tar extraction
|
# Prevents sdist builds → no tar extraction
|
||||||
PIP_ONLY_BINARY: ":all:"
|
PIP_ONLY_BINARY: ":all:"
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||||
@@ -31,13 +28,6 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
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
|
- name: Set up Python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
@@ -57,47 +47,4 @@ jobs:
|
|||||||
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
|
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 end-of-file-fixer --all-files -c .pre-commit-config.yaml
|
||||||
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
|
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
|
||||||
continue-on-error: true
|
git diff --exit-code
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|||||||
+2
-2
@@ -110,11 +110,11 @@ tasks.register('syncAppVersion') {
|
|||||||
[new File(sim1Path), new File(sim2Path)].each { f ->
|
[new File(sim1Path), new File(sim2Path)].each { f ->
|
||||||
if (f.exists()) {
|
if (f.exists()) {
|
||||||
def content = f.getText('UTF-8')
|
def content = f.getText('UTF-8')
|
||||||
def matcher = (content =~ /(appVersion:\s*')([^']*)(')/)
|
def matcher = (content =~ /(appVersion:\s*(['"]))(.*?)(\2)/)
|
||||||
if (!matcher.find()) {
|
if (!matcher.find()) {
|
||||||
throw new GradleException("Could not locate appVersion in ${f} for synchronization")
|
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) {
|
if (content != updatedContent) {
|
||||||
f.write(updatedContent, 'UTF-8')
|
f.write(updatedContent, 'UTF-8')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
public/vendor/
|
||||||
|
public/pdfjs*/
|
||||||
|
public/js/thirdParty/
|
||||||
|
public/css/cookieconsent.css
|
||||||
|
*.min.*
|
||||||
|
*.md
|
||||||
|
*.wxs
|
||||||
|
src/output.css
|
||||||
+38
-48
@@ -1,62 +1,52 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import eslint from '@eslint/js';
|
import eslint from "@eslint/js";
|
||||||
import globals from 'globals';
|
import globals from "globals";
|
||||||
import { defineConfig } from 'eslint/config';
|
import { defineConfig } from "eslint/config";
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
const srcGlobs = [
|
const srcGlobs = ["src/**/*.{js,mjs,jsx,ts,tsx}"];
|
||||||
'src/**/*.{js,mjs,jsx,ts,tsx}',
|
const nodeGlobs = ["scripts/**/*.{js,ts,mjs}", "*.config.{js,ts,mjs}"];
|
||||||
];
|
|
||||||
const nodeGlobs = [
|
|
||||||
'scripts/**/*.{js,ts,mjs}',
|
|
||||||
'*.config.{js,ts,mjs}',
|
|
||||||
];
|
|
||||||
|
|
||||||
const baseRestrictedImportPatterns = [
|
const baseRestrictedImportPatterns = [
|
||||||
{ regex: '^\\.', message: "Use @app/* imports instead of relative imports." },
|
{ regex: "^\\.", message: "Use @app/* imports instead of relative imports." },
|
||||||
{ regex: '^src/', message: "Use @app/* imports instead of absolute src/ imports." },
|
{ regex: "^src/", message: "Use @app/* imports instead of absolute src/ imports." },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
{
|
{
|
||||||
// Everything that contains 3rd party code that we don't want to lint
|
// Everything that contains 3rd party code that we don't want to lint
|
||||||
ignores: [
|
ignores: ["dist", "node_modules", "public", "src-tauri"],
|
||||||
'dist',
|
|
||||||
'node_modules',
|
|
||||||
'public',
|
|
||||||
'src-tauri',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
'no-restricted-imports': [
|
"no-restricted-imports": [
|
||||||
'error',
|
"error",
|
||||||
{
|
{
|
||||||
patterns: baseRestrictedImportPatterns,
|
patterns: baseRestrictedImportPatterns,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'@typescript-eslint/no-empty-object-type': [
|
"@typescript-eslint/no-empty-object-type": [
|
||||||
'error',
|
"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
|
// 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-explicit-any": "off", // Temporarily disabled until codebase conformant
|
||||||
'@typescript-eslint/no-require-imports': 'off', // Temporarily disabled until codebase conformant
|
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
|
||||||
'@typescript-eslint/no-unused-vars': [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
'error',
|
"error",
|
||||||
{
|
{
|
||||||
'args': 'all', // All function args must be used (or explicitly ignored)
|
args: "all", // All function args must be used (or explicitly ignored)
|
||||||
'argsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
|
argsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
|
||||||
'caughtErrors': 'all', // Caught errors must be used (or explicitly ignored)
|
caughtErrors: "all", // Caught errors must be used (or explicitly ignored)
|
||||||
'caughtErrorsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
|
caughtErrorsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
|
||||||
'destructuredArrayIgnorePattern': '^_', // Allow unused variables beginning with an underscore
|
destructuredArrayIgnorePattern: "^_", // Allow unused variables beginning with an underscore
|
||||||
'varsIgnorePattern': '^_', // 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)
|
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/.
|
// Use the stub/shadow pattern instead: define a stub in src/core/ and override in src/desktop/.
|
||||||
{
|
{
|
||||||
files: srcGlobs,
|
files: srcGlobs,
|
||||||
ignores: ['src/desktop/**'],
|
ignores: ["src/desktop/**"],
|
||||||
rules: {
|
rules: {
|
||||||
'no-restricted-imports': [
|
"no-restricted-imports": [
|
||||||
'error',
|
"error",
|
||||||
{
|
{
|
||||||
patterns: [
|
patterns: [
|
||||||
...baseRestrictedImportPatterns,
|
...baseRestrictedImportPatterns,
|
||||||
{
|
{
|
||||||
regex: '^@tauri-apps/',
|
regex: "^@tauri-apps/",
|
||||||
message: "Tauri APIs are desktop-only. Review frontend/DeveloperGuide.md for structure advice.",
|
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
|
// Folders that have been cleaned up and are now conformant - stricter rules enforced here
|
||||||
{
|
{
|
||||||
files: [
|
files: [
|
||||||
'src/proprietary/**/*.{js,mjs,jsx,ts,tsx}',
|
"src/proprietary/**/*.{js,mjs,jsx,ts,tsx}",
|
||||||
'src/saas/**/*.{js,mjs,jsx,ts,tsx}',
|
"src/saas/**/*.{js,mjs,jsx,ts,tsx}",
|
||||||
'src/prototypes/**/*.{js,mjs,jsx,ts,tsx}',
|
"src/prototypes/**/*.{js,mjs,jsx,ts,tsx}",
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
@@ -95,8 +85,8 @@ export default defineConfig(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-explicit-any': 'error',
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Config for browser scripts
|
// Config for browser scripts
|
||||||
@@ -105,8 +95,8 @@ export default defineConfig(
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
// Config for node scripts
|
// Config for node scripts
|
||||||
{
|
{
|
||||||
@@ -114,7 +104,7 @@ export default defineConfig(
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
...globals.node,
|
...globals.node,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-5
@@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en-GB">
|
<html lang="en-GB">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
@@ -6,10 +6,7 @@
|
|||||||
<link rel="icon" href="modern-logo/favicon.ico" />
|
<link rel="icon" href="modern-logo/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta name="description" content="The Free Adobe Acrobat alternative (10M+ Downloads)" />
|
||||||
name="description"
|
|
||||||
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
|
|
||||||
/>
|
|
||||||
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
|
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
|
||||||
<link rel="manifest" href="manifest.json" />
|
<link rel="manifest" href="manifest.json" />
|
||||||
|
|
||||||
|
|||||||
Generated
+17
@@ -114,6 +114,7 @@
|
|||||||
"postcss-cli": "^11.0.1",
|
"postcss-cli": "^11.0.1",
|
||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
"puppeteer": "^24.25.0",
|
"puppeteer": "^24.25.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
@@ -11308,6 +11309,22 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/pretty-format": {
|
||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||||
|
|||||||
@@ -89,8 +89,12 @@
|
|||||||
"dev:saas": "npm run prep:saas && vite --mode saas",
|
"dev:saas": "npm run prep:saas && vite --mode saas",
|
||||||
"dev:desktop": "npm run prep:desktop && vite --mode desktop",
|
"dev:desktop": "npm run prep:desktop && vite --mode desktop",
|
||||||
"dev:prototypes": "npm run prep && vite --mode prototypes",
|
"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": "npm run lint:eslint && npm run lint:cycles",
|
||||||
"lint:eslint": "eslint --max-warnings=0",
|
"lint:eslint": "eslint --max-warnings=0",
|
||||||
|
"lint:fix": "eslint --fix",
|
||||||
"lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1",
|
"lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1",
|
||||||
"build": "npm run prep && vite build",
|
"build": "npm run prep && vite build",
|
||||||
"build:core": "npm run prep && vite build --mode core",
|
"build:core": "npm run prep && vite build --mode core",
|
||||||
@@ -176,6 +180,7 @@
|
|||||||
"postcss-cli": "^11.0.1",
|
"postcss-cli": "^11.0.1",
|
||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
"puppeteer": "^24.25.0",
|
"puppeteer": "^24.25.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://playwright.dev/docs/test-configuration
|
* @see https://playwright.dev/docs/test-configuration
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './src/core/tests',
|
testDir: "./src/core/tests",
|
||||||
testMatch: '**/*.spec.ts',
|
testMatch: "**/*.spec.ts",
|
||||||
/* Run tests in files in parallel */
|
/* Run tests in files in parallel */
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* 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. */
|
/* Opt out of parallel tests on CI. */
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* 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. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* 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 */
|
/* 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 */
|
/* Configure projects for major browsers */
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: "chromium",
|
||||||
use: {
|
use: {
|
||||||
...devices['Desktop Chrome'],
|
...devices["Desktop Chrome"],
|
||||||
viewport: { width: 1920, height: 1080 }
|
viewport: { width: 1920, height: 1080 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'firefox',
|
name: "firefox",
|
||||||
use: { ...devices['Desktop Firefox'] },
|
use: { ...devices["Desktop Firefox"] },
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'webkit',
|
name: "webkit",
|
||||||
use: { ...devices['Desktop Safari'] },
|
use: { ...devices["Desktop Safari"] },
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Test against mobile viewports. */
|
/* Test against mobile viewports. */
|
||||||
@@ -68,8 +68,8 @@ export default defineConfig({
|
|||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
command: "npm run dev",
|
||||||
url: 'http://localhost:5173',
|
url: "http://localhost:5173",
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: [
|
plugins: [require("@tailwindcss/postcss"), require("autoprefixer")],
|
||||||
require('@tailwindcss/postcss'),
|
|
||||||
require('autoprefixer'),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,206 +1,205 @@
|
|||||||
/* Light theme variables */
|
/* Light theme variables */
|
||||||
:root {
|
:root {
|
||||||
--cc-bg: #ffffff;
|
--cc-bg: #ffffff;
|
||||||
--cc-primary-color: #1c1c1c;
|
--cc-primary-color: #1c1c1c;
|
||||||
--cc-secondary-color: #666666;
|
--cc-secondary-color: #666666;
|
||||||
|
|
||||||
--cc-btn-primary-bg: #007BFF;
|
--cc-btn-primary-bg: #007bff;
|
||||||
--cc-btn-primary-color: #ffffff;
|
--cc-btn-primary-color: #ffffff;
|
||||||
--cc-btn-primary-border-color: #007BFF;
|
--cc-btn-primary-border-color: #007bff;
|
||||||
--cc-btn-primary-hover-bg: #0056b3;
|
--cc-btn-primary-hover-bg: #0056b3;
|
||||||
--cc-btn-primary-hover-color: #ffffff;
|
--cc-btn-primary-hover-color: #ffffff;
|
||||||
--cc-btn-primary-hover-border-color: #0056b3;
|
--cc-btn-primary-hover-border-color: #0056b3;
|
||||||
|
|
||||||
--cc-btn-secondary-bg: #f1f3f4;
|
--cc-btn-secondary-bg: #f1f3f4;
|
||||||
--cc-btn-secondary-color: #1c1c1c;
|
--cc-btn-secondary-color: #1c1c1c;
|
||||||
--cc-btn-secondary-border-color: #f1f3f4;
|
--cc-btn-secondary-border-color: #f1f3f4;
|
||||||
--cc-btn-secondary-hover-bg: #007BFF;
|
--cc-btn-secondary-hover-bg: #007bff;
|
||||||
--cc-btn-secondary-hover-color: #ffffff;
|
--cc-btn-secondary-hover-color: #ffffff;
|
||||||
--cc-btn-secondary-hover-border-color: #007BFF;
|
--cc-btn-secondary-hover-border-color: #007bff;
|
||||||
|
|
||||||
--cc-separator-border-color: #e0e0e0;
|
--cc-separator-border-color: #e0e0e0;
|
||||||
|
|
||||||
--cc-toggle-on-bg: #007BFF;
|
--cc-toggle-on-bg: #007bff;
|
||||||
--cc-toggle-off-bg: #667481;
|
--cc-toggle-off-bg: #667481;
|
||||||
--cc-toggle-on-knob-bg: #ffffff;
|
--cc-toggle-on-knob-bg: #ffffff;
|
||||||
--cc-toggle-off-knob-bg: #ffffff;
|
--cc-toggle-off-knob-bg: #ffffff;
|
||||||
|
|
||||||
--cc-toggle-enabled-icon-color: #ffffff;
|
--cc-toggle-enabled-icon-color: #ffffff;
|
||||||
--cc-toggle-disabled-icon-color: #ffffff;
|
--cc-toggle-disabled-icon-color: #ffffff;
|
||||||
|
|
||||||
--cc-toggle-readonly-bg: #f1f3f4;
|
--cc-toggle-readonly-bg: #f1f3f4;
|
||||||
--cc-toggle-readonly-knob-bg: #79747E;
|
--cc-toggle-readonly-knob-bg: #79747e;
|
||||||
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
|
--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-bg: #f1f3f4;
|
||||||
--cc-cookie-category-block-border: #f1f3f4;
|
--cc-cookie-category-block-border: #f1f3f4;
|
||||||
--cc-cookie-category-block-hover-bg: #e9eff4;
|
--cc-cookie-category-block-hover-bg: #e9eff4;
|
||||||
--cc-cookie-category-block-hover-border: #e9eff4;
|
--cc-cookie-category-block-hover-border: #e9eff4;
|
||||||
|
|
||||||
--cc-cookie-category-expanded-block-bg: #f1f3f4;
|
--cc-cookie-category-expanded-block-bg: #f1f3f4;
|
||||||
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
|
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
|
||||||
|
|
||||||
--cc-footer-bg: #ffffff;
|
--cc-footer-bg: #ffffff;
|
||||||
--cc-footer-color: #1c1c1c;
|
--cc-footer-color: #1c1c1c;
|
||||||
--cc-footer-border-color: #ffffff;
|
--cc-footer-border-color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme variables */
|
/* Dark theme variables */
|
||||||
.cc--darkmode{
|
.cc--darkmode {
|
||||||
--cc-bg: #2d2d2d;
|
--cc-bg: #2d2d2d;
|
||||||
--cc-primary-color: #e5e5e5;
|
--cc-primary-color: #e5e5e5;
|
||||||
--cc-secondary-color: #b0b0b0;
|
--cc-secondary-color: #b0b0b0;
|
||||||
|
|
||||||
--cc-btn-primary-bg: #4dabf7;
|
--cc-btn-primary-bg: #4dabf7;
|
||||||
--cc-btn-primary-color: #ffffff;
|
--cc-btn-primary-color: #ffffff;
|
||||||
--cc-btn-primary-border-color: #4dabf7;
|
--cc-btn-primary-border-color: #4dabf7;
|
||||||
--cc-btn-primary-hover-bg: #3d3d3d;
|
--cc-btn-primary-hover-bg: #3d3d3d;
|
||||||
--cc-btn-primary-hover-color: #ffffff;
|
--cc-btn-primary-hover-color: #ffffff;
|
||||||
--cc-btn-primary-hover-border-color: #3d3d3d;
|
--cc-btn-primary-hover-border-color: #3d3d3d;
|
||||||
|
|
||||||
--cc-btn-secondary-bg: #3d3d3d;
|
--cc-btn-secondary-bg: #3d3d3d;
|
||||||
--cc-btn-secondary-color: #ffffff;
|
--cc-btn-secondary-color: #ffffff;
|
||||||
--cc-btn-secondary-border-color: #3d3d3d;
|
--cc-btn-secondary-border-color: #3d3d3d;
|
||||||
--cc-btn-secondary-hover-bg: #4dabf7;
|
--cc-btn-secondary-hover-bg: #4dabf7;
|
||||||
--cc-btn-secondary-hover-color: #ffffff;
|
--cc-btn-secondary-hover-color: #ffffff;
|
||||||
--cc-btn-secondary-hover-border-color: #4dabf7;
|
--cc-btn-secondary-hover-border-color: #4dabf7;
|
||||||
|
|
||||||
--cc-separator-border-color: #555555;
|
--cc-separator-border-color: #555555;
|
||||||
|
|
||||||
--cc-toggle-on-bg: #4dabf7;
|
--cc-toggle-on-bg: #4dabf7;
|
||||||
--cc-toggle-off-bg: #667481;
|
--cc-toggle-off-bg: #667481;
|
||||||
--cc-toggle-on-knob-bg: #2d2d2d;
|
--cc-toggle-on-knob-bg: #2d2d2d;
|
||||||
--cc-toggle-off-knob-bg: #2d2d2d;
|
--cc-toggle-off-knob-bg: #2d2d2d;
|
||||||
|
|
||||||
--cc-toggle-enabled-icon-color: #2d2d2d;
|
--cc-toggle-enabled-icon-color: #2d2d2d;
|
||||||
--cc-toggle-disabled-icon-color: #2d2d2d;
|
--cc-toggle-disabled-icon-color: #2d2d2d;
|
||||||
|
|
||||||
--cc-toggle-readonly-bg: #555555;
|
--cc-toggle-readonly-bg: #555555;
|
||||||
--cc-toggle-readonly-knob-bg: #8e8e8e;
|
--cc-toggle-readonly-knob-bg: #8e8e8e;
|
||||||
--cc-toggle-readonly-knob-icon-color: #555555;
|
--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-bg: #3d3d3d;
|
||||||
--cc-cookie-category-block-border: #3d3d3d;
|
--cc-cookie-category-block-border: #3d3d3d;
|
||||||
--cc-cookie-category-block-hover-bg: #4d4d4d;
|
--cc-cookie-category-block-hover-bg: #4d4d4d;
|
||||||
--cc-cookie-category-block-hover-border: #4d4d4d;
|
--cc-cookie-category-block-hover-border: #4d4d4d;
|
||||||
|
|
||||||
--cc-cookie-category-expanded-block-bg: #3d3d3d;
|
--cc-cookie-category-expanded-block-bg: #3d3d3d;
|
||||||
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
|
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
|
||||||
|
|
||||||
--cc-footer-bg: #2d2d2d;
|
--cc-footer-bg: #2d2d2d;
|
||||||
--cc-footer-color: #e5e5e5;
|
--cc-footer-color: #e5e5e5;
|
||||||
--cc-footer-border-color: #2d2d2d;
|
--cc-footer-border-color: #2d2d2d;
|
||||||
}
|
}
|
||||||
.cm__body{
|
.cm__body {
|
||||||
max-width: 90% !important;
|
max-width: 90% !important;
|
||||||
flex-direction: row !important;
|
flex-direction: row !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm__desc{
|
.cm__desc {
|
||||||
max-width: 70rem !important;
|
max-width: 70rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm__btns{
|
.cm__btns {
|
||||||
flex-direction: row-reverse !important;
|
flex-direction: row-reverse !important;
|
||||||
gap:10px !important;
|
gap: 10px !important;
|
||||||
padding-top: 3.4rem !important;
|
padding-top: 3.4rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 1400px) {
|
@media only screen and (max-width: 1400px) {
|
||||||
.cm__body{
|
.cm__body {
|
||||||
max-width: 90% !important;
|
max-width: 90% !important;
|
||||||
flex-direction: column !important;
|
flex-direction: column !important;
|
||||||
align-items: normal !important;
|
align-items: normal !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm__btns{
|
.cm__btns {
|
||||||
padding-top: 1rem !important;
|
padding-top: 1rem !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toggle visibility fixes */
|
/* Toggle visibility fixes */
|
||||||
#cc-main .section__toggle {
|
#cc-main .section__toggle {
|
||||||
opacity: 0 !important; /* Keep invisible but functional */
|
opacity: 0 !important; /* Keep invisible but functional */
|
||||||
}
|
}
|
||||||
|
|
||||||
#cc-main .toggle__icon {
|
#cc-main .toggle__icon {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
justify-content: flex-start !important;
|
justify-content: flex-start !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cc-main .toggle__icon-circle {
|
#cc-main .toggle__icon-circle {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
transition: transform 0.25s ease !important;
|
transition: transform 0.25s ease !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cc-main .toggle__icon-on,
|
#cc-main .toggle__icon-on,
|
||||||
#cc-main .toggle__icon-off {
|
#cc-main .toggle__icon-off {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure toggles are visible in both themes */
|
/* Ensure toggles are visible in both themes */
|
||||||
#cc-main .toggle__icon {
|
#cc-main .toggle__icon {
|
||||||
background: var(--cc-toggle-off-bg) !important;
|
background: var(--cc-toggle-off-bg) !important;
|
||||||
border: 1px solid var(--cc-toggle-off-bg) !important;
|
border: 1px solid var(--cc-toggle-off-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cc-main .section__toggle:checked ~ .toggle__icon {
|
#cc-main .section__toggle:checked ~ .toggle__icon {
|
||||||
background: var(--cc-toggle-on-bg) !important;
|
background: var(--cc-toggle-on-bg) !important;
|
||||||
border: 1px solid var(--cc-toggle-on-bg) !important;
|
border: 1px solid var(--cc-toggle-on-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure toggle text is visible */
|
/* Ensure toggle text is visible */
|
||||||
#cc-main .pm__section-title {
|
#cc-main .pm__section-title {
|
||||||
color: var(--cc-primary-color) !important;
|
color: var(--cc-primary-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cc-main .pm__section-desc {
|
#cc-main .pm__section-desc {
|
||||||
color: var(--cc-secondary-color) !important;
|
color: var(--cc-secondary-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make sure the modal has proper contrast */
|
/* Make sure the modal has proper contrast */
|
||||||
#cc-main .pm {
|
#cc-main .pm {
|
||||||
background: var(--cc-bg) !important;
|
background: var(--cc-bg) !important;
|
||||||
color: var(--cc-primary-color) !important;
|
color: var(--cc-primary-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||||
#cc-main {
|
#cc-main {
|
||||||
z-index: 100 !important;
|
z-index: 100 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure consent modal text is visible in both themes */
|
/* Ensure consent modal text is visible in both themes */
|
||||||
#cc-main .cm {
|
#cc-main .cm {
|
||||||
background: var(--cc-bg) !important;
|
background: var(--cc-bg) !important;
|
||||||
color: var(--cc-primary-color) !important;
|
color: var(--cc-primary-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cc-main .cm__title {
|
#cc-main .cm__title {
|
||||||
color: var(--cc-primary-color) !important;
|
color: var(--cc-primary-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cc-main .cm__desc {
|
#cc-main .cm__desc {
|
||||||
color: var(--cc-primary-color) !important;
|
color: var(--cc-primary-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cc-main .cm__footer {
|
#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__footer-links a,
|
||||||
#cc-main .cm__link {
|
#cc-main .cm__link {
|
||||||
color: var(--cc-primary-color) !important;
|
color: var(--cc-primary-color) !important;
|
||||||
}
|
}
|
||||||
@@ -23,4 +23,3 @@
|
|||||||
"theme_color": "#000000",
|
"theme_color": "#000000",
|
||||||
"background_color": "#ffffff"
|
"background_color": "#ffffff"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,24 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from "node:child_process";
|
||||||
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, copyFileSync } from "node:fs";
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== "win32") {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const frontendDir = process.cwd();
|
const frontendDir = process.cwd();
|
||||||
const tauriDir = resolve(frontendDir, 'src-tauri');
|
const tauriDir = resolve(frontendDir, "src-tauri");
|
||||||
const provisionerManifest = join(tauriDir, 'provisioner', 'Cargo.toml');
|
const provisionerManifest = join(tauriDir, "provisioner", "Cargo.toml");
|
||||||
|
|
||||||
execFileSync(
|
execFileSync("cargo", ["build", "--release", "--manifest-path", provisionerManifest], { stdio: "inherit" });
|
||||||
'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)) {
|
if (!existsSync(provisionerExe)) {
|
||||||
throw new Error(`Provisioner binary not found at ${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 });
|
mkdirSync(wixDir, { recursive: true });
|
||||||
|
|
||||||
const destExe = join(wixDir, 'stirling-provision.exe');
|
const destExe = join(wixDir, "stirling-provision.exe");
|
||||||
copyFileSync(provisionerExe, destExe);
|
copyFileSync(provisionerExe, destExe);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
const { icons } = require('@iconify-json/material-symbols');
|
const { icons } = require("@iconify-json/material-symbols");
|
||||||
const fs = require('fs');
|
const fs = require("fs");
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
|
|
||||||
// Check for verbose flag
|
// 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
|
// Logging functions
|
||||||
const info = (message) => console.log(message);
|
const info = (message) => console.log(message);
|
||||||
@@ -18,12 +18,12 @@ const debug = (message) => {
|
|||||||
// Function to scan codebase for LocalIcon usage
|
// Function to scan codebase for LocalIcon usage
|
||||||
function scanForUsedIcons() {
|
function scanForUsedIcons() {
|
||||||
const usedIcons = new Set();
|
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)) {
|
if (!fs.existsSync(srcDir)) {
|
||||||
console.error('❌ Source directory not found:', srcDir);
|
console.error("❌ Source directory not found:", srcDir);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,19 +31,19 @@ function scanForUsedIcons() {
|
|||||||
function scanDirectory(dir) {
|
function scanDirectory(dir) {
|
||||||
const files = fs.readdirSync(dir);
|
const files = fs.readdirSync(dir);
|
||||||
|
|
||||||
files.forEach(file => {
|
files.forEach((file) => {
|
||||||
const filePath = path.join(dir, file);
|
const filePath = path.join(dir, file);
|
||||||
const stat = fs.statSync(filePath);
|
const stat = fs.statSync(filePath);
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
scanDirectory(filePath);
|
scanDirectory(filePath);
|
||||||
} else if (file.endsWith('.tsx') || file.endsWith('.ts')) {
|
} else if (file.endsWith(".tsx") || file.endsWith(".ts")) {
|
||||||
const content = fs.readFileSync(filePath, 'utf8');
|
const content = fs.readFileSync(filePath, "utf8");
|
||||||
|
|
||||||
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...>
|
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...>
|
||||||
const localIconMatches = content.match(/<LocalIcon\s+[^>]*icon="([^"]+)"/g);
|
const localIconMatches = content.match(/<LocalIcon\s+[^>]*icon="([^"]+)"/g);
|
||||||
if (localIconMatches) {
|
if (localIconMatches) {
|
||||||
localIconMatches.forEach(match => {
|
localIconMatches.forEach((match) => {
|
||||||
const iconMatch = match.match(/icon="([^"]+)"/);
|
const iconMatch = match.match(/icon="([^"]+)"/);
|
||||||
if (iconMatch) {
|
if (iconMatch) {
|
||||||
usedIcons.add(iconMatch[1]);
|
usedIcons.add(iconMatch[1]);
|
||||||
@@ -55,7 +55,7 @@ function scanForUsedIcons() {
|
|||||||
// Match LocalIcon usage: <LocalIcon icon='icon-name' ...>
|
// Match LocalIcon usage: <LocalIcon icon='icon-name' ...>
|
||||||
const localIconSingleQuoteMatches = content.match(/<LocalIcon\s+[^>]*icon='([^']+)'/g);
|
const localIconSingleQuoteMatches = content.match(/<LocalIcon\s+[^>]*icon='([^']+)'/g);
|
||||||
if (localIconSingleQuoteMatches) {
|
if (localIconSingleQuoteMatches) {
|
||||||
localIconSingleQuoteMatches.forEach(match => {
|
localIconSingleQuoteMatches.forEach((match) => {
|
||||||
const iconMatch = match.match(/icon='([^']+)'/);
|
const iconMatch = match.match(/icon='([^']+)'/);
|
||||||
if (iconMatch) {
|
if (iconMatch) {
|
||||||
usedIcons.add(iconMatch[1]);
|
usedIcons.add(iconMatch[1]);
|
||||||
@@ -67,7 +67,7 @@ function scanForUsedIcons() {
|
|||||||
// Match old material-symbols-rounded spans: <span className="material-symbols-rounded">icon-name</span>
|
// Match old material-symbols-rounded spans: <span className="material-symbols-rounded">icon-name</span>
|
||||||
const spanMatches = content.match(/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g);
|
const spanMatches = content.match(/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g);
|
||||||
if (spanMatches) {
|
if (spanMatches) {
|
||||||
spanMatches.forEach(match => {
|
spanMatches.forEach((match) => {
|
||||||
const iconMatch = match.match(/>([^<]+)<\/span>/);
|
const iconMatch = match.match(/>([^<]+)<\/span>/);
|
||||||
if (iconMatch && iconMatch[1].trim()) {
|
if (iconMatch && iconMatch[1].trim()) {
|
||||||
const iconName = iconMatch[1].trim();
|
const iconName = iconMatch[1].trim();
|
||||||
@@ -80,7 +80,7 @@ function scanForUsedIcons() {
|
|||||||
// Match Icon component usage: <Icon icon="material-symbols:icon-name" ...>
|
// Match Icon component usage: <Icon icon="material-symbols:icon-name" ...>
|
||||||
const iconMatches = content.match(/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g);
|
const iconMatches = content.match(/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g);
|
||||||
if (iconMatches) {
|
if (iconMatches) {
|
||||||
iconMatches.forEach(match => {
|
iconMatches.forEach((match) => {
|
||||||
const iconMatch = match.match(/icon="material-symbols:([^"]+)"/);
|
const iconMatch = match.match(/icon="material-symbols:([^"]+)"/);
|
||||||
if (iconMatch) {
|
if (iconMatch) {
|
||||||
usedIcons.add(iconMatch[1]);
|
usedIcons.add(iconMatch[1]);
|
||||||
@@ -92,7 +92,7 @@ function scanForUsedIcons() {
|
|||||||
// Match icon config usage: icon: 'icon-name' or icon: "icon-name"
|
// Match icon config usage: icon: 'icon-name' or icon: "icon-name"
|
||||||
const iconPropertyMatches = content.match(/icon:\s*(['"])([a-z0-9-]+)\1/g);
|
const iconPropertyMatches = content.match(/icon:\s*(['"])([a-z0-9-]+)\1/g);
|
||||||
if (iconPropertyMatches) {
|
if (iconPropertyMatches) {
|
||||||
iconPropertyMatches.forEach(match => {
|
iconPropertyMatches.forEach((match) => {
|
||||||
const iconMatch = match.match(/icon:\s*(['"])([a-z0-9-]+)\1/);
|
const iconMatch = match.match(/icon:\s*(['"])([a-z0-9-]+)\1/);
|
||||||
if (iconMatch) {
|
if (iconMatch) {
|
||||||
usedIcons.add(iconMatch[2]);
|
usedIcons.add(iconMatch[2]);
|
||||||
@@ -118,18 +118,20 @@ async function main() {
|
|||||||
const usedIcons = scanForUsedIcons();
|
const usedIcons = scanForUsedIcons();
|
||||||
|
|
||||||
// Check if we need to regenerate (compare with existing)
|
// 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;
|
let needsRegeneration = true;
|
||||||
|
|
||||||
if (fs.existsSync(outputPath)) {
|
if (fs.existsSync(outputPath)) {
|
||||||
try {
|
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 existingIcons = Object.keys(existingSet.icons || {}).sort();
|
||||||
const currentIcons = [...usedIcons].sort();
|
const currentIcons = [...usedIcons].sort();
|
||||||
|
|
||||||
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
|
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
|
||||||
needsRegeneration = false;
|
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 {
|
} catch {
|
||||||
// If we can't parse existing file, regenerate
|
// If we can't parse existing file, regenerate
|
||||||
@@ -138,34 +140,34 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!needsRegeneration) {
|
if (!needsRegeneration) {
|
||||||
info('🎉 No regeneration needed!');
|
info("🎉 No regeneration needed!");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`);
|
info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`);
|
||||||
|
|
||||||
// Dynamic import of ES module
|
// 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
|
// Extract only our used icons from the full set
|
||||||
const extractedIcons = getIcons(icons, usedIcons);
|
const extractedIcons = getIcons(icons, usedIcons);
|
||||||
|
|
||||||
if (!extractedIcons) {
|
if (!extractedIcons) {
|
||||||
console.error('❌ Failed to extract icons');
|
console.error("❌ Failed to extract icons");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for missing icons
|
// Check for missing icons
|
||||||
const extractedIconNames = Object.keys(extractedIcons.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) {
|
if (missingIcons.length > 0) {
|
||||||
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`);
|
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`);
|
||||||
info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.');
|
info("💡 These icons don't exist in Material Symbols. Please use available alternatives.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output directory
|
// Create output directory
|
||||||
const outputDir = path.join(__dirname, '..', 'src', 'assets');
|
const outputDir = path.join(__dirname, "..", "src", "assets");
|
||||||
if (!fs.existsSync(outputDir)) {
|
if (!fs.existsSync(outputDir)) {
|
||||||
fs.mkdirSync(outputDir, { recursive: true });
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -182,7 +184,7 @@ async function main() {
|
|||||||
// This file is automatically generated by scripts/generate-icons.js
|
// This file is automatically generated by scripts/generate-icons.js
|
||||||
// Do not edit manually - changes will be overwritten
|
// 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 {
|
export interface IconSet {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
@@ -196,7 +198,7 @@ declare const iconSet: IconSet;
|
|||||||
export default 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);
|
fs.writeFileSync(typesPath, typesContent);
|
||||||
|
|
||||||
info(`📝 Generated types: ${typesPath}`);
|
info(`📝 Generated types: ${typesPath}`);
|
||||||
@@ -204,7 +206,7 @@ export default iconSet;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run the main function
|
// Run the main function
|
||||||
main().catch(error => {
|
main().catch((error) => {
|
||||||
console.error('❌ Script failed:', error);
|
console.error("❌ Script failed:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
const { execSync } = require('node:child_process');
|
const { execSync } = require("node:child_process");
|
||||||
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('node:fs');
|
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require("node:fs");
|
||||||
const path = require('node:path');
|
const path = require("node:path");
|
||||||
|
|
||||||
const { argv } = require('node:process');
|
const { argv } = require("node:process");
|
||||||
const inputIdx = argv.indexOf('--input');
|
const inputIdx = argv.indexOf("--input");
|
||||||
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
|
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
|
||||||
const POSTPROCESS_ONLY = !!INPUT_FILE;
|
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
|
* 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 OUTPUT_FILE = path.join(__dirname, "..", "src", "assets", "3rdPartyLicenses.json");
|
||||||
const PACKAGE_JSON = path.join(__dirname, '..', 'package.json');
|
const PACKAGE_JSON = path.join(__dirname, "..", "package.json");
|
||||||
|
|
||||||
// Ensure the output directory exists
|
// Ensure the output directory exists
|
||||||
const outputDir = path.dirname(OUTPUT_FILE);
|
const outputDir = path.dirname(OUTPUT_FILE);
|
||||||
if (!existsSync(outputDir)) {
|
if (!existsSync(outputDir)) {
|
||||||
mkdirSync(outputDir, { recursive: true });
|
mkdirSync(outputDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔍 Generating frontend license report...');
|
console.log("🔍 Generating frontend license report...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
|
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
|
||||||
if (process.env.PR_IS_FORK === 'true' && !POSTPROCESS_ONLY) {
|
if (process.env.PR_IS_FORK === "true" && !POSTPROCESS_ONLY) {
|
||||||
console.error('Fork PR detected: only --input (postprocess-only) mode is allowed.');
|
console.error("Fork PR detected: only --input (postprocess-only) mode is allowed.");
|
||||||
process.exit(2);
|
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);
|
||||||
}
|
}
|
||||||
|
licenseData = JSON.parse(readFileSync(INPUT_FILE, "utf8"));
|
||||||
let licenseData;
|
} else {
|
||||||
// Generate license report using pinned license-checker; disable lifecycle scripts
|
const licenseReport = execSync(
|
||||||
if (POSTPROCESS_ONLY) {
|
// 'npx --yes [email protected] --production --json',
|
||||||
if (!INPUT_FILE || !existsSync(INPUT_FILE)) {
|
"npx --yes license-report --only=prod --output=json",
|
||||||
console.error('❌ --input file missing or not found');
|
{
|
||||||
process.exit(1);
|
encoding: "utf8",
|
||||||
}
|
cwd: path.dirname(PACKAGE_JSON),
|
||||||
licenseData = JSON.parse(readFileSync(INPUT_FILE, 'utf8'));
|
env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: "true" },
|
||||||
} else {
|
},
|
||||||
const licenseReport = execSync(
|
|
||||||
// 'npx --yes [email protected] --production --json',
|
|
||||||
'npx --yes license-report --only=prod --output=json',
|
|
||||||
{
|
|
||||||
encoding: 'utf8',
|
|
||||||
cwd: path.dirname(PACKAGE_JSON),
|
|
||||||
env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: 'true' }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
licenseData = JSON.parse(licenseReport);
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('❌ Failed to parse license data:', parseError.message);
|
|
||||||
console.error('Raw output:', licenseReport.substring(0, 500) + '...');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(licenseData)) {
|
|
||||||
console.error('❌ Invalid license data structure');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert license-checker format to array
|
|
||||||
const licenseArray = licenseData.map(dep => {
|
|
||||||
let licenseType = dep.licenseType;
|
|
||||||
|
|
||||||
// Handle missing or null licenses
|
|
||||||
if (!licenseType || licenseType === null || licenseType === undefined) {
|
|
||||||
licenseType = 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle empty string licenses
|
|
||||||
if (licenseType === '') {
|
|
||||||
licenseType = 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle array licenses (rare but possible)
|
|
||||||
if (Array.isArray(licenseType)) {
|
|
||||||
licenseType = licenseType.join(' AND ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle object licenses (fallback)
|
|
||||||
if (typeof licenseType === 'object' && licenseType !== null) {
|
|
||||||
licenseType = 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( "posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) {
|
|
||||||
licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: dep.name,
|
|
||||||
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || 'unknown',
|
|
||||||
licenseType: licenseType,
|
|
||||||
repository: dep.link,
|
|
||||||
url: dep.link,
|
|
||||||
link: dep.link
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transform to match Java backend format
|
|
||||||
const transformedData = {
|
|
||||||
dependencies: licenseArray.map(dep => {
|
|
||||||
const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
|
|
||||||
const licenseUrl = dep.link || getLicenseUrl(licenseType);
|
|
||||||
|
|
||||||
return {
|
|
||||||
moduleName: dep.name,
|
|
||||||
moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
|
||||||
moduleVersion: dep.version,
|
|
||||||
moduleLicense: licenseType,
|
|
||||||
moduleLicenseUrl: licenseUrl
|
|
||||||
};
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log summary of license types found
|
|
||||||
const licenseSummary = licenseArray.reduce((acc, dep) => {
|
|
||||||
const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
|
|
||||||
acc[license] = (acc[license] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
console.log('📊 License types found:');
|
|
||||||
Object.entries(licenseSummary).forEach(([license, count]) => {
|
|
||||||
console.log(` ${license}: ${count} packages`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log any complex or unusual license formats for debugging
|
|
||||||
const complexLicenses = licenseArray.filter(dep =>
|
|
||||||
dep.licenseType && (
|
|
||||||
dep.licenseType.includes('AND') ||
|
|
||||||
dep.licenseType.includes('OR') ||
|
|
||||||
dep.licenseType === 'Unknown' ||
|
|
||||||
dep.licenseType.includes('SEE LICENSE')
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
if (complexLicenses.length > 0) {
|
licenseData = JSON.parse(licenseReport);
|
||||||
console.log('\n🔍 Complex/Edge case licenses detected:');
|
} catch (parseError) {
|
||||||
complexLicenses.forEach(dep => {
|
console.error("❌ Failed to parse license data:", parseError.message);
|
||||||
console.log(` ${dep.name}@${dep.version}: "${dep.licenseType}"`);
|
console.error("Raw output:", licenseReport.substring(0, 500) + "...");
|
||||||
});
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for potentially problematic licenses
|
if (!Array.isArray(licenseData)) {
|
||||||
const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray);
|
console.error("❌ Invalid license data structure");
|
||||||
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);
|
|
||||||
process.exit(1);
|
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
|
* Get standard license URLs for common licenses
|
||||||
*/
|
*/
|
||||||
function getLicenseUrl(licenseType) {
|
function getLicenseUrl(licenseType) {
|
||||||
if (!licenseType || licenseType === 'Unknown') return '';
|
if (!licenseType || licenseType === "Unknown") return "";
|
||||||
|
|
||||||
const licenseUrls = {
|
const licenseUrls = {
|
||||||
'MIT': 'https://opensource.org/licenses/MIT',
|
MIT: "https://opensource.org/licenses/MIT",
|
||||||
'MIT*': 'https://opensource.org/licenses/MIT',
|
"MIT*": "https://opensource.org/licenses/MIT",
|
||||||
'Apache-2.0': 'https://www.apache.org/licenses/LICENSE-2.0',
|
"Apache-2.0": "https://www.apache.org/licenses/LICENSE-2.0",
|
||||||
'Apache License 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-3-Clause": "https://opensource.org/licenses/BSD-3-Clause",
|
||||||
'BSD-2-Clause': 'https://opensource.org/licenses/BSD-2-Clause',
|
"BSD-2-Clause": "https://opensource.org/licenses/BSD-2-Clause",
|
||||||
'BSD': 'https://opensource.org/licenses/BSD-3-Clause',
|
BSD: "https://opensource.org/licenses/BSD-3-Clause",
|
||||||
'GPL-3.0': 'https://www.gnu.org/licenses/gpl-3.0.html',
|
"GPL-3.0": "https://www.gnu.org/licenses/gpl-3.0.html",
|
||||||
'GPL-2.0': 'https://www.gnu.org/licenses/gpl-2.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-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',
|
"LGPL-3.0": "https://www.gnu.org/licenses/lgpl-3.0.html",
|
||||||
'ISC': 'https://opensource.org/licenses/ISC',
|
ISC: "https://opensource.org/licenses/ISC",
|
||||||
'CC0-1.0': 'https://creativecommons.org/publicdomain/zero/1.0/',
|
"CC0-1.0": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||||
'Unlicense': 'https://unlicense.org/',
|
Unlicense: "https://unlicense.org/",
|
||||||
'MPL-2.0': 'https://www.mozilla.org/en-US/MPL/2.0/',
|
"MPL-2.0": "https://www.mozilla.org/en-US/MPL/2.0/",
|
||||||
'WTFPL': 'http://www.wtfpl.net/',
|
WTFPL: "http://www.wtfpl.net/",
|
||||||
'Zlib': 'https://opensource.org/licenses/Zlib',
|
Zlib: "https://opensource.org/licenses/Zlib",
|
||||||
'Artistic-2.0': 'https://opensource.org/licenses/Artistic-2.0',
|
"Artistic-2.0": "https://opensource.org/licenses/Artistic-2.0",
|
||||||
'EPL-1.0': 'https://www.eclipse.org/legal/epl-v10.html',
|
"EPL-1.0": "https://www.eclipse.org/legal/epl-v10.html",
|
||||||
'EPL-2.0': 'https://www.eclipse.org/legal/epl-2.0/',
|
"EPL-2.0": "https://www.eclipse.org/legal/epl-2.0/",
|
||||||
'CDDL-1.0': 'https://opensource.org/licenses/CDDL-1.0',
|
"CDDL-1.0": "https://opensource.org/licenses/CDDL-1.0",
|
||||||
'Ruby': 'https://www.ruby-lang.org/en/about/license.txt',
|
Ruby: "https://www.ruby-lang.org/en/about/license.txt",
|
||||||
'Python-2.0': 'https://www.python.org/download/releases/2.0/license/',
|
"Python-2.0": "https://www.python.org/download/releases/2.0/license/",
|
||||||
'Public Domain': 'https://creativecommons.org/publicdomain/zero/1.0/',
|
"Public Domain": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||||
'UNLICENSED': ''
|
UNLICENSED: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try exact match first
|
// Try exact match first
|
||||||
if (licenseUrls[licenseType]) {
|
if (licenseUrls[licenseType]) {
|
||||||
return 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
|
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
||||||
const lowerType = licenseType.toLowerCase();
|
if (licenseType.includes("AND") || licenseType.includes("OR")) {
|
||||||
for (const [key, url] of Object.entries(licenseUrls)) {
|
// Extract the first license from compound expressions for URL
|
||||||
if (key.toLowerCase() === lowerType) {
|
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
|
||||||
return url;
|
if (match && licenseUrls[match[1]]) {
|
||||||
}
|
return licenseUrls[match[1]];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
// For non-standard licenses, return empty string (will use package link if available)
|
||||||
if (licenseType.includes('AND') || licenseType.includes('OR')) {
|
return "";
|
||||||
// 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 '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for potentially problematic licenses that may not be MIT/corporate compatible
|
* Check for potentially problematic licenses that may not be MIT/corporate compatible
|
||||||
*/
|
*/
|
||||||
function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
|
|
||||||
// Define problematic license patterns
|
// Define problematic license patterns
|
||||||
const problematicLicenses = {
|
const problematicLicenses = {
|
||||||
// Copyleft licenses
|
// Copyleft licenses
|
||||||
'GPL-2.0': 'Strong copyleft license - requires derivative works to be GPL',
|
"GPL-2.0": "Strong copyleft license - requires derivative works to be GPL",
|
||||||
'GPL-3.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-2.1": "Weak copyleft license - may require source disclosure for modifications",
|
||||||
'LGPL-3.0': '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-3.0": "Network copyleft license - requires source disclosure for network use",
|
||||||
'AGPL-1.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
|
// Other potentially problematic licenses
|
||||||
'WTFPL': 'Potentially problematic license - legal uncertainty',
|
WTFPL: "Potentially problematic license - legal uncertainty",
|
||||||
'CC-BY-SA-4.0': 'ShareAlike license - requires derivative works to use same license',
|
"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-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-4.0": "Non-commercial license - prohibits commercial use",
|
||||||
'CC-BY-NC-3.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',
|
"OSL-3.0": "Copyleft license - requires derivative works to be OSL",
|
||||||
'EPL-1.0': 'Weak copyleft license - may require source disclosure',
|
"EPL-1.0": "Weak copyleft license - may require source disclosure",
|
||||||
'EPL-2.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.0": "Weak copyleft license - may require source disclosure",
|
||||||
'CDDL-1.1': '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',
|
"CPL-1.0": "Weak copyleft license - may require source disclosure",
|
||||||
'MPL-1.1': '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.1": "Copyleft license - requires derivative works to be EUPL",
|
||||||
'EUPL-1.2': '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',
|
UNLICENSED: "No license specified - usage rights unclear",
|
||||||
'Unknown': 'License not detected - manual review required'
|
Unknown: "License not detected - manual review required",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Known good licenses (no warnings needed)
|
// Known good licenses (no warnings needed)
|
||||||
const goodLicenses = new Set([
|
const goodLicenses = new Set([
|
||||||
'MIT', 'MIT*', 'Apache-2.0', 'Apache License 2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD',
|
"MIT",
|
||||||
'ISC', 'CC0-1.0', 'Public Domain', 'Unlicense', '0BSD', 'BlueOak-1.0.0',
|
"MIT*",
|
||||||
'Zlib', 'Artistic-2.0', 'Python-2.0', 'Ruby', 'MPL-2.0', 'CC-BY-4.0',
|
"Apache-2.0",
|
||||||
'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE',
|
"Apache License 2.0",
|
||||||
'SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE'
|
"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
|
// Helper function to normalize license names for comparison
|
||||||
function normalizeLicense(license) {
|
function normalizeLicense(license) {
|
||||||
return license
|
return license
|
||||||
.replace(/-or-later$/, '') // Remove -or-later suffix
|
.replace(/-or-later$/, "") // Remove -or-later suffix
|
||||||
.replace(/\+$/, '') // Remove + suffix
|
.replace(/\+$/, "") // Remove + suffix
|
||||||
.trim();
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each license type
|
||||||
|
Object.entries(licenseSummary).forEach(([license, count]) => {
|
||||||
|
// Skip known good licenses
|
||||||
|
if (goodLicenses.has(license)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check each license type
|
// Check if this license only affects our own packages
|
||||||
Object.entries(licenseSummary).forEach(([license, count]) => {
|
const affectedPackages = licenseArray.filter((dep) => {
|
||||||
// Skip known good licenses
|
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
|
||||||
if (goodLicenses.has(license)) {
|
return depLicense === 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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,20 +8,20 @@
|
|||||||
* for users to experiment with Stirling PDF's features.
|
* for users to experiment with Stirling PDF's features.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from "puppeteer";
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from "url";
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from "path";
|
||||||
import { existsSync, mkdirSync, statSync } from 'fs';
|
import { existsSync, mkdirSync, statSync } from "fs";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
const TEMPLATE_PATH = join(__dirname, 'template.html');
|
const TEMPLATE_PATH = join(__dirname, "template.html");
|
||||||
const OUTPUT_DIR = join(__dirname, '../../public/samples');
|
const OUTPUT_DIR = join(__dirname, "../../public/samples");
|
||||||
const OUTPUT_PATH = join(OUTPUT_DIR, 'Sample.pdf');
|
const OUTPUT_PATH = join(OUTPUT_DIR, "Sample.pdf");
|
||||||
|
|
||||||
async function generatePDF() {
|
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
|
// Ensure output directory exists
|
||||||
if (!existsSync(OUTPUT_DIR)) {
|
if (!existsSync(OUTPUT_DIR)) {
|
||||||
@@ -40,66 +40,65 @@ async function generatePDF() {
|
|||||||
let browser;
|
let browser;
|
||||||
try {
|
try {
|
||||||
// Launch Puppeteer
|
// Launch Puppeteer
|
||||||
console.log('🌐 Launching browser...');
|
console.log("🌐 Launching browser...");
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: 'new',
|
headless: "new",
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
|
||||||
// Set viewport to match A4 proportions
|
// Set viewport to match A4 proportions
|
||||||
await page.setViewport({
|
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
|
height: 1123, // A4 height in pixels at 96 DPI
|
||||||
deviceScaleFactor: 2 // Higher quality rendering
|
deviceScaleFactor: 2, // Higher quality rendering
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to the template file
|
// Navigate to the template file
|
||||||
const fileUrl = `file://${TEMPLATE_PATH}`;
|
const fileUrl = `file://${TEMPLATE_PATH}`;
|
||||||
console.log('📖 Loading HTML template...');
|
console.log("📖 Loading HTML template...");
|
||||||
await page.goto(fileUrl, {
|
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
|
// Generate PDF with A4 dimensions
|
||||||
console.log('📝 Generating PDF...');
|
console.log("📝 Generating PDF...");
|
||||||
await page.pdf({
|
await page.pdf({
|
||||||
path: OUTPUT_PATH,
|
path: OUTPUT_PATH,
|
||||||
format: 'A4',
|
format: "A4",
|
||||||
printBackground: true,
|
printBackground: true,
|
||||||
margin: {
|
margin: {
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 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}`);
|
console.log(`📦 Output: ${OUTPUT_PATH}`);
|
||||||
|
|
||||||
// Get file size
|
// Get file size
|
||||||
const stats = statSync(OUTPUT_PATH);
|
const stats = statSync(OUTPUT_PATH);
|
||||||
const fileSizeInKB = (stats.size / 1024).toFixed(2);
|
const fileSizeInKB = (stats.size / 1024).toFixed(2);
|
||||||
console.log(`📊 File size: ${fileSizeInKB} KB`);
|
console.log(`📊 File size: ${fileSizeInKB} KB`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('\n❌ Error generating PDF:', error.message);
|
console.error("\n❌ Error generating PDF:", error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
} finally {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
await browser.close();
|
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
|
// Run the generator
|
||||||
generatePDF().catch(error => {
|
generatePDF().catch((error) => {
|
||||||
console.error('Fatal error:', error);
|
console.error("Fatal error:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,8 +20,9 @@
|
|||||||
--color-white: #ffffff;
|
--color-white: #ffffff;
|
||||||
|
|
||||||
/* Font Stack */
|
/* Font Stack */
|
||||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
--font-family:
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
|
||||||
|
"Helvetica Neue", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -1,234 +1,244 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Stirling PDF - Sample Document</title>
|
<title>Stirling PDF - Sample Document</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Page 1: Hero / Cover Page -->
|
<!-- Page 1: Hero / Cover Page -->
|
||||||
<div class="page page-1">
|
<div class="page page-1">
|
||||||
<div class="decorative-shapes">
|
<div class="decorative-shapes">
|
||||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="">
|
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="" />
|
||||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="">
|
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="" />
|
||||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="">
|
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="" />
|
||||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="">
|
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="" />
|
||||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" alt="">
|
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" alt="" />
|
||||||
</div>
|
|
||||||
<div class="hero-content">
|
|
||||||
<div class="logo-container">
|
|
||||||
<img src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo">
|
|
||||||
</div>
|
</div>
|
||||||
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
|
<div class="hero-content">
|
||||||
<div class="hero-stats">
|
<div class="logo-container">
|
||||||
<div class="stat-badge">
|
<img src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo" />
|
||||||
<span class="stat-number">10M+</span>
|
|
||||||
<span class="stat-label">Downloads</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
|
||||||
<div class="hero-features">
|
<div class="hero-stats">
|
||||||
<div class="feature-pill">Open Source</div>
|
<div class="stat-badge">
|
||||||
<div class="feature-pill">Privacy First</div>
|
<span class="stat-number">10M+</span>
|
||||||
<div class="feature-pill">Self-Hosted</div>
|
<span class="stat-label">Downloads</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Page 2: What is Stirling PDF -->
|
|
||||||
<div class="page page-2">
|
|
||||||
<div class="content-wrapper">
|
|
||||||
<h2 class="page-title">What is Stirling PDF?</h2>
|
|
||||||
<p class="intro-text">
|
|
||||||
Stirling PDF is a robust, web-based PDF manipulation tool.
|
|
||||||
It enables you to carry out various operations on PDF files, including splitting,
|
|
||||||
merging, converting, rearranging, adding images, rotating, compressing, and more.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="value-props">
|
|
||||||
<div class="value-prop">
|
|
||||||
<div class="value-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<h3>50+ PDF Operations</h3>
|
|
||||||
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hero-features">
|
||||||
<div class="value-prop">
|
<div class="feature-pill">Open Source</div>
|
||||||
<div class="value-icon">
|
<div class="feature-pill">Privacy First</div>
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
<div class="feature-pill">Self-Hosted</div>
|
||||||
<path d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3>Workflow Automation</h3>
|
|
||||||
<p>Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="value-prop">
|
|
||||||
<div class="value-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="2" y1="12" x2="22" y2="12" />
|
|
||||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3>Multi-Language Support</h3>
|
|
||||||
<p>Available in over 30 languages with community-contributed translations. Accessible to users worldwide.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="value-prop">
|
|
||||||
<div class="value-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
|
||||||
<circle cx="12" cy="16" r="1" />
|
|
||||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3>Privacy First</h3>
|
|
||||||
<p>Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="value-prop">
|
|
||||||
<div class="value-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="m9 12 2 2 4-4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3>Open Source</h3>
|
|
||||||
<p>Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="value-prop">
|
|
||||||
<div class="value-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<polyline points="16 18 22 12 16 6" />
|
|
||||||
<polyline points="8 6 2 12 8 18" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3>API Access</h3>
|
|
||||||
<p>RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Page 3: Key Features -->
|
<!-- Page 2: What is Stirling PDF -->
|
||||||
<div class="page page-3">
|
<div class="page page-2">
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<h2 class="page-title">Key Features</h2>
|
<h2 class="page-title">What is Stirling PDF?</h2>
|
||||||
|
<p class="intro-text">
|
||||||
|
Stirling PDF is a robust, web-based PDF manipulation tool. It enables you to carry out various operations on PDF
|
||||||
|
files, including splitting, merging, converting, rearranging, adding images, rotating, compressing, and more.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="features-grid">
|
<div class="value-props">
|
||||||
<div class="feature-card" data-category="general">
|
<div class="value-prop">
|
||||||
<div class="feature-header">
|
<div class="value-icon">
|
||||||
<div class="feature-icon-large">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
<path
|
||||||
<polyline points="14 2 14 8 20 8" />
|
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
/>
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
|
||||||
<polyline points="10 9 9 9 8 9" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3>Page Operations</h3>
|
<h3>50+ PDF Operations</h3>
|
||||||
|
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="feature-list">
|
|
||||||
<li>Merge & split PDFs</li>
|
|
||||||
<li>Rearrange pages</li>
|
|
||||||
<li>Rotate & crop</li>
|
|
||||||
<li>Extract pages</li>
|
|
||||||
<li>Multi-page layout</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card" data-category="security">
|
<div class="value-prop">
|
||||||
<div class="feature-header">
|
<div class="value-icon">
|
||||||
<div class="feature-icon-large">
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Workflow Automation</h3>
|
||||||
|
<p>Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="value-prop">
|
||||||
|
<div class="value-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
<line x1="2" y1="12" x2="22" y2="12" />
|
||||||
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3>Security & Signing</h3>
|
<h3>Multi-Language Support</h3>
|
||||||
|
<p>Available in over 30 languages with community-contributed translations. Accessible to users worldwide.</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="feature-list">
|
|
||||||
<li>Password protection</li>
|
|
||||||
<li>Digital signatures</li>
|
|
||||||
<li>Watermarks</li>
|
|
||||||
<li>Permission controls</li>
|
|
||||||
<li>Redaction tools</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card" data-category="formatting">
|
<div class="value-prop">
|
||||||
<div class="feature-header">
|
<div class="value-icon">
|
||||||
<div class="feature-icon-large">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||||
<path d="m5.825 17l1.9 1.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-3.6-3.6q-.15-.15-.213-.325T2.426 16t.063-.375t.212-.325l3.6-3.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L5.825 15H20q.425 0 .713.288T21 16t-.288.713T20 17zm12.35-8H4q-.425 0-.712-.288T3 8t.288-.712T4 7h14.175l-1.9-1.9q-.3-.3-.287-.7t.312-.7q.3-.275.7-.288t.7.288l3.6 3.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.6 3.6q-.275.275-.687.275T16.3 12.3q-.3-.3-.3-.712t.3-.713z" />
|
<circle cx="12" cy="16" r="1" />
|
||||||
|
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3>File Conversions</h3>
|
<h3>Privacy First</h3>
|
||||||
|
<p>
|
||||||
|
Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="feature-list">
|
|
||||||
<li>PDF to/from images</li>
|
|
||||||
<li>Office documents</li>
|
|
||||||
<li>HTML to PDF</li>
|
|
||||||
<li>Markdown to PDF</li>
|
|
||||||
<li>PDF to Word/Excel</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card" data-category="automation">
|
<div class="value-prop">
|
||||||
<div class="feature-header">
|
<div class="value-icon">
|
||||||
<div class="feature-icon-large">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
<circle cx="12" cy="12" r="10" />
|
||||||
<path d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2" />
|
<path d="m9 12 2 2 4-4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3>Automation</h3>
|
<h3>Open Source</h3>
|
||||||
|
<p>Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="feature-list">
|
|
||||||
<li>Multi-step workflows</li>
|
|
||||||
<li>Chain PDF operations</li>
|
|
||||||
<li>Save recurring tasks</li>
|
|
||||||
<li>Batch file processing</li>
|
|
||||||
<li>API integration</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="additional-features">
|
<div class="value-prop">
|
||||||
<div class="additional-features-header">
|
<div class="value-icon">
|
||||||
<div class="additional-features-icon">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
<polyline points="16 18 22 12 16 6" />
|
||||||
<path d="M6 20q-.825 0-1.4125-.5875T4 18t.5875-1.4125T6 16t1.4125.5875T8 18t-.5875 1.4125T6 20m6 0q-.825 0-1.4125-.5875T10 18t.5875-1.4125T12 16t1.4125.5875T14 18t-.5875 1.4125T12 20m6 0q-.825 0-1.4125-.5875T16 18t.5875-1.4125T18 16t1.4125.5875T20 18t-.5875 1.4125T18 20M6 14q-.825 0-1.4125-.5875T4 12t.5875-1.4125T6 10t1.4125.5875T8 12t-.5875 1.4125T6 14m6 0q-.825 0-1.4125-.5875T10 12t.5875-1.4125T12 10t1.4125.5875T14 12t-.5875 1.4125T12 14m6 0q-.825 0-1.4125-.5875T16 12t.5875-1.4125T18 10t1.4125.5875T20 12t-.5875 1.4125T18 14M6 8q-.825 0-1.4125-.5875T4 6t.5875-1.4125T6 4t1.4125.5875T8 6t-.5875 1.4125T6 8m6 0q-.825 0-1.4125-.5875T10 6t.5875-1.4125T12 4t1.4125.5875T14 6t-.5875 1.4125T12 8m6 0q-.825 0-1.4125-.5875T16 6t.5875-1.4125T18 4t1.4125.5875T20 6t-.5875 1.4125T18 8" />
|
<polyline points="8 6 2 12 8 18" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>API Access</h3>
|
||||||
|
<p>RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.</p>
|
||||||
</div>
|
</div>
|
||||||
<h3>Plus Many More</h3>
|
|
||||||
</div>
|
|
||||||
<div class="additional-features-grid">
|
|
||||||
<ul>
|
|
||||||
<li>OCR text recognition</li>
|
|
||||||
<li>Compress PDFs</li>
|
|
||||||
<li>Add images & stamps</li>
|
|
||||||
<li>Detect blank pages</li>
|
|
||||||
<li>Extract images</li>
|
|
||||||
<li>Edit metadata</li>
|
|
||||||
</ul>
|
|
||||||
<ul>
|
|
||||||
<li>Flatten forms</li>
|
|
||||||
<li>PDF/A conversion</li>
|
|
||||||
<li>Add page numbers</li>
|
|
||||||
<li>Remove pages</li>
|
|
||||||
<li>Repair PDFs</li>
|
|
||||||
<li>And 40+ more tools</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
<!-- Page 3: Key Features -->
|
||||||
|
<div class="page page-3">
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<h2 class="page-title">Key Features</h2>
|
||||||
|
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="feature-card" data-category="general">
|
||||||
|
<div class="feature-header">
|
||||||
|
<div class="feature-icon-large">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10 9 9 9 8 9" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Page Operations</h3>
|
||||||
|
</div>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Merge & split PDFs</li>
|
||||||
|
<li>Rearrange pages</li>
|
||||||
|
<li>Rotate & crop</li>
|
||||||
|
<li>Extract pages</li>
|
||||||
|
<li>Multi-page layout</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card" data-category="security">
|
||||||
|
<div class="feature-header">
|
||||||
|
<div class="feature-icon-large">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Security & Signing</h3>
|
||||||
|
</div>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Password protection</li>
|
||||||
|
<li>Digital signatures</li>
|
||||||
|
<li>Watermarks</li>
|
||||||
|
<li>Permission controls</li>
|
||||||
|
<li>Redaction tools</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card" data-category="formatting">
|
||||||
|
<div class="feature-header">
|
||||||
|
<div class="feature-icon-large">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="m5.825 17l1.9 1.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-3.6-3.6q-.15-.15-.213-.325T2.426 16t.063-.375t.212-.325l3.6-3.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L5.825 15H20q.425 0 .713.288T21 16t-.288.713T20 17zm12.35-8H4q-.425 0-.712-.288T3 8t.288-.712T4 7h14.175l-1.9-1.9q-.3-.3-.287-.7t.312-.7q.3-.275.7-.288t.7.288l3.6 3.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.6 3.6q-.275.275-.687.275T16.3 12.3q-.3-.3-.3-.712t.3-.713z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>File Conversions</h3>
|
||||||
|
</div>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>PDF to/from images</li>
|
||||||
|
<li>Office documents</li>
|
||||||
|
<li>HTML to PDF</li>
|
||||||
|
<li>Markdown to PDF</li>
|
||||||
|
<li>PDF to Word/Excel</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card" data-category="automation">
|
||||||
|
<div class="feature-header">
|
||||||
|
<div class="feature-icon-large">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Automation</h3>
|
||||||
|
</div>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>Multi-step workflows</li>
|
||||||
|
<li>Chain PDF operations</li>
|
||||||
|
<li>Save recurring tasks</li>
|
||||||
|
<li>Batch file processing</li>
|
||||||
|
<li>API integration</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="additional-features">
|
||||||
|
<div class="additional-features-header">
|
||||||
|
<div class="additional-features-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M6 20q-.825 0-1.4125-.5875T4 18t.5875-1.4125T6 16t1.4125.5875T8 18t-.5875 1.4125T6 20m6 0q-.825 0-1.4125-.5875T10 18t.5875-1.4125T12 16t1.4125.5875T14 18t-.5875 1.4125T12 20m6 0q-.825 0-1.4125-.5875T16 18t.5875-1.4125T18 16t1.4125.5875T20 18t-.5875 1.4125T18 20M6 14q-.825 0-1.4125-.5875T4 12t.5875-1.4125T6 10t1.4125.5875T8 12t-.5875 1.4125T6 14m6 0q-.825 0-1.4125-.5875T10 12t.5875-1.4125T12 10t1.4125.5875T14 12t-.5875 1.4125T12 14m6 0q-.825 0-1.4125-.5875T16 12t.5875-1.4125T18 10t1.4125.5875T20 12t-.5875 1.4125T18 14M6 8q-.825 0-1.4125-.5875T4 6t.5875-1.4125T6 4t1.4125.5875T8 6t-.5875 1.4125T6 8m6 0q-.825 0-1.4125-.5875T10 6t.5875-1.4125T12 4t1.4125.5875T14 6t-.5875 1.4125T12 8m6 0q-.825 0-1.4125-.5875T16 6t.5875-1.4125T18 4t1.4125.5875T20 6t-.5875 1.4125T18 8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Plus Many More</h3>
|
||||||
|
</div>
|
||||||
|
<div class="additional-features-grid">
|
||||||
|
<ul>
|
||||||
|
<li>OCR text recognition</li>
|
||||||
|
<li>Compress PDFs</li>
|
||||||
|
<li>Add images & stamps</li>
|
||||||
|
<li>Detect blank pages</li>
|
||||||
|
<li>Extract images</li>
|
||||||
|
<li>Edit metadata</li>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
<li>Flatten forms</li>
|
||||||
|
<li>PDF/A conversion</li>
|
||||||
|
<li>Add page numbers</li>
|
||||||
|
<li>Remove pages</li>
|
||||||
|
<li>Repair PDFs</li>
|
||||||
|
<li>And 40+ more tools</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -10,22 +10,22 @@
|
|||||||
* tsx scripts/setup-env.ts --saas # also checks .env.saas
|
* tsx scripts/setup-env.ts --saas # also checks .env.saas
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, copyFileSync, readFileSync } from 'fs';
|
import { existsSync, copyFileSync, readFileSync } from "fs";
|
||||||
import { join } from 'path';
|
import { join } from "path";
|
||||||
import { config, parse } from 'dotenv';
|
import { config, parse } from "dotenv";
|
||||||
|
|
||||||
// npm scripts run from the directory containing package.json (frontend/)
|
// npm scripts run from the directory containing package.json (frontend/)
|
||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const isDesktop = args.includes('--desktop');
|
const isDesktop = args.includes("--desktop");
|
||||||
const isSaas = args.includes('--saas');
|
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[] {
|
function getExampleKeys(exampleFile: string): string[] {
|
||||||
const examplePath = join(root, exampleFile);
|
const examplePath = join(root, exampleFile);
|
||||||
if (!existsSync(examplePath)) return [];
|
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 {
|
function ensureEnvFile(envFile: string, exampleFile: string): boolean {
|
||||||
@@ -44,13 +44,13 @@ function ensureEnvFile(envFile: string, exampleFile: string): boolean {
|
|||||||
|
|
||||||
config({ path: envPath });
|
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) {
|
if (missing.length > 0) {
|
||||||
console.error(
|
console.error(
|
||||||
`setup-env: ${envFile} is missing keys from ${exampleFile}:\n` +
|
`setup-env: ${envFile} is missing keys from ${exampleFile}:\n` +
|
||||||
missing.map(k => ` ${k}`).join('\n') +
|
missing.map((k) => ` ${k}`).join("\n") +
|
||||||
'\n Add them manually or delete your local file to re-copy from the example.'
|
"\n Add them manually or delete your local file to re-copy from the example.",
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -59,29 +59,28 @@ function ensureEnvFile(envFile: string, exampleFile: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let failed = false;
|
let failed = false;
|
||||||
failed = ensureEnvFile('.env', 'config/.env.example') || failed;
|
failed = ensureEnvFile(".env", "config/.env.example") || failed;
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
failed = ensureEnvFile('.env.desktop', 'config/.env.desktop.example') || failed;
|
failed = ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSaas) {
|
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.
|
// Warn about any VITE_ vars set in the environment that aren't listed in any example file.
|
||||||
const allExampleKeys = new Set([
|
const allExampleKeys = new Set([
|
||||||
...getExampleKeys('config/.env.example'),
|
...getExampleKeys("config/.env.example"),
|
||||||
...getExampleKeys('config/.env.desktop.example'),
|
...getExampleKeys("config/.env.desktop.example"),
|
||||||
...getExampleKeys('config/.env.saas.example'),
|
...getExampleKeys("config/.env.saas.example"),
|
||||||
]);
|
]);
|
||||||
const unknownViteVars = Object.keys(process.env)
|
const unknownViteVars = Object.keys(process.env).filter((k) => k.startsWith("VITE_") && !allExampleKeys.has(k));
|
||||||
.filter(k => k.startsWith('VITE_') && !allExampleKeys.has(k));
|
|
||||||
if (unknownViteVars.length > 0) {
|
if (unknownViteVars.length > 0) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'setup-env: the following VITE_ vars are set but not listed in any example file:\n' +
|
"setup-env: the following VITE_ vars are set but not listed in any example file:\n" +
|
||||||
unknownViteVars.map(k => ` ${k}`).join('\n') +
|
unknownViteVars.map((k) => ` ${k}`).join("\n") +
|
||||||
'\n Add them to the appropriate config/.env.*.example file if they are required.'
|
"\n Add them to the appropriate config/.env.*.example file if they are required.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "enables the default permissions",
|
"description": "enables the default permissions",
|
||||||
"windows": [
|
"windows": ["main"],
|
||||||
"main"
|
|
||||||
],
|
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-destroy",
|
"core:window:allow-destroy",
|
||||||
|
|||||||
@@ -1,98 +1,82 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "Stirling-PDF",
|
"productName": "Stirling-PDF",
|
||||||
"version": "2.9.2",
|
"version": "2.9.2",
|
||||||
"identifier": "stirling.pdf.dev",
|
"identifier": "stirling.pdf.dev",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5173",
|
||||||
"beforeDevCommand": "npm run dev -- --mode desktop",
|
"beforeDevCommand": "npm run dev -- --mode desktop",
|
||||||
"beforeBuildCommand": "node scripts/build-provisioner.mjs && npm run build -- --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": {
|
||||||
"windows": [
|
"certificateThumbprint": null,
|
||||||
{
|
"digestAlgorithm": "sha256",
|
||||||
"title": "Stirling-PDF",
|
"timestampUrl": "http://timestamp.digicert.com",
|
||||||
"width": 1280,
|
"wix": {
|
||||||
"height": 800,
|
"fragmentPaths": ["windows/wix/provisioning.wxs"],
|
||||||
"resizable": true,
|
"componentGroupRefs": ["ProvisioningComponentGroup"]
|
||||||
"fullscreen": false,
|
}
|
||||||
"additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"bundle": {
|
"macOS": {
|
||||||
"active": true,
|
"minimumSystemVersion": "10.15",
|
||||||
"publisher": "Stirling PDF Inc.",
|
"signingIdentity": null,
|
||||||
"targets": [
|
"entitlements": null,
|
||||||
"deb",
|
"providerShortName": null,
|
||||||
"rpm",
|
"infoPlist": "Info.plist"
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"shell": {
|
||||||
|
"open": true
|
||||||
|
},
|
||||||
|
"fs": {
|
||||||
|
"requireLiteralLeadingDot": false
|
||||||
|
},
|
||||||
|
"deep-link": {
|
||||||
|
"desktop": {
|
||||||
|
"schemes": ["stirlingpdf"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,326 +1,326 @@
|
|||||||
{
|
{
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
{
|
{
|
||||||
"moduleName": "@atlaskit/pragmatic-drag-and-drop",
|
"moduleName": "@atlaskit/pragmatic-drag-and-drop",
|
||||||
"moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git",
|
"moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git",
|
||||||
"moduleVersion": "1.7.7",
|
"moduleVersion": "1.7.7",
|
||||||
"moduleLicense": "Apache-2.0",
|
"moduleLicense": "Apache-2.0",
|
||||||
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
|
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/core",
|
"moduleName": "@embedpdf/core",
|
||||||
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
|
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
|
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/engines",
|
"moduleName": "@embedpdf/engines",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-annotation",
|
"moduleName": "@embedpdf/plugin-annotation",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-export",
|
"moduleName": "@embedpdf/plugin-export",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-history",
|
"moduleName": "@embedpdf/plugin-history",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-interaction-manager",
|
"moduleName": "@embedpdf/plugin-interaction-manager",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-loader",
|
"moduleName": "@embedpdf/plugin-loader",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-pan",
|
"moduleName": "@embedpdf/plugin-pan",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-render",
|
"moduleName": "@embedpdf/plugin-render",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-rotate",
|
"moduleName": "@embedpdf/plugin-rotate",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-scroll",
|
"moduleName": "@embedpdf/plugin-scroll",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-search",
|
"moduleName": "@embedpdf/plugin-search",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-selection",
|
"moduleName": "@embedpdf/plugin-selection",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-spread",
|
"moduleName": "@embedpdf/plugin-spread",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-thumbnail",
|
"moduleName": "@embedpdf/plugin-thumbnail",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-tiling",
|
"moduleName": "@embedpdf/plugin-tiling",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-viewport",
|
"moduleName": "@embedpdf/plugin-viewport",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@embedpdf/plugin-zoom",
|
"moduleName": "@embedpdf/plugin-zoom",
|
||||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
"moduleVersion": "1.3.0",
|
"moduleVersion": "1.3.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@emotion/react",
|
"moduleName": "@emotion/react",
|
||||||
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
|
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
|
||||||
"moduleVersion": "11.14.0",
|
"moduleVersion": "11.14.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
|
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@emotion/styled",
|
"moduleName": "@emotion/styled",
|
||||||
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
|
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
|
||||||
"moduleVersion": "11.14.1",
|
"moduleVersion": "11.14.1",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
|
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@iconify/react",
|
"moduleName": "@iconify/react",
|
||||||
"moduleUrl": "git+https://github.com/iconify/iconify.git",
|
"moduleUrl": "git+https://github.com/iconify/iconify.git",
|
||||||
"moduleVersion": "6.0.2",
|
"moduleVersion": "6.0.2",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/iconify/iconify.git"
|
"moduleLicenseUrl": "git+https://github.com/iconify/iconify.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@mantine/core",
|
"moduleName": "@mantine/core",
|
||||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||||
"moduleVersion": "8.3.1",
|
"moduleVersion": "8.3.1",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@mantine/dates",
|
"moduleName": "@mantine/dates",
|
||||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||||
"moduleVersion": "8.3.1",
|
"moduleVersion": "8.3.1",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@mantine/dropzone",
|
"moduleName": "@mantine/dropzone",
|
||||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||||
"moduleVersion": "8.3.1",
|
"moduleVersion": "8.3.1",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@mantine/hooks",
|
"moduleName": "@mantine/hooks",
|
||||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||||
"moduleVersion": "8.3.1",
|
"moduleVersion": "8.3.1",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@mui/icons-material",
|
"moduleName": "@mui/icons-material",
|
||||||
"moduleUrl": "git+https://github.com/mui/material-ui.git",
|
"moduleUrl": "git+https://github.com/mui/material-ui.git",
|
||||||
"moduleVersion": "7.3.2",
|
"moduleVersion": "7.3.2",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
|
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@mui/material",
|
"moduleName": "@mui/material",
|
||||||
"moduleUrl": "git+https://github.com/mui/material-ui.git",
|
"moduleUrl": "git+https://github.com/mui/material-ui.git",
|
||||||
"moduleVersion": "7.3.2",
|
"moduleVersion": "7.3.2",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
|
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@tailwindcss/postcss",
|
"moduleName": "@tailwindcss/postcss",
|
||||||
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
|
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
|
||||||
"moduleVersion": "4.1.13",
|
"moduleVersion": "4.1.13",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
|
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@tanstack/react-virtual",
|
"moduleName": "@tanstack/react-virtual",
|
||||||
"moduleUrl": "git+https://github.com/TanStack/virtual.git",
|
"moduleUrl": "git+https://github.com/TanStack/virtual.git",
|
||||||
"moduleVersion": "3.13.12",
|
"moduleVersion": "3.13.12",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git"
|
"moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "autoprefixer",
|
"moduleName": "autoprefixer",
|
||||||
"moduleUrl": "git+https://github.com/postcss/autoprefixer.git",
|
"moduleUrl": "git+https://github.com/postcss/autoprefixer.git",
|
||||||
"moduleVersion": "10.4.21",
|
"moduleVersion": "10.4.21",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git"
|
"moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "axios",
|
"moduleName": "axios",
|
||||||
"moduleUrl": "git+https://github.com/axios/axios.git",
|
"moduleUrl": "git+https://github.com/axios/axios.git",
|
||||||
"moduleVersion": "1.12.2",
|
"moduleVersion": "1.12.2",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/axios/axios.git"
|
"moduleLicenseUrl": "git+https://github.com/axios/axios.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "i18next",
|
"moduleName": "i18next",
|
||||||
"moduleUrl": "git+https://github.com/i18next/i18next.git",
|
"moduleUrl": "git+https://github.com/i18next/i18next.git",
|
||||||
"moduleVersion": "25.5.2",
|
"moduleVersion": "25.5.2",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/i18next/i18next.git"
|
"moduleLicenseUrl": "git+https://github.com/i18next/i18next.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "i18next-browser-languagedetector",
|
"moduleName": "i18next-browser-languagedetector",
|
||||||
"moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git",
|
"moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git",
|
||||||
"moduleVersion": "8.2.0",
|
"moduleVersion": "8.2.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git"
|
"moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "i18next-http-backend",
|
"moduleName": "i18next-http-backend",
|
||||||
"moduleUrl": "git+ssh://[email protected]/i18next/i18next-http-backend.git",
|
"moduleUrl": "git+ssh://[email protected]/i18next/i18next-http-backend.git",
|
||||||
"moduleVersion": "3.0.2",
|
"moduleVersion": "3.0.2",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+ssh://[email protected]/i18next/i18next-http-backend.git"
|
"moduleLicenseUrl": "git+ssh://[email protected]/i18next/i18next-http-backend.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "jszip",
|
"moduleName": "jszip",
|
||||||
"moduleUrl": "git+https://github.com/Stuk/jszip.git",
|
"moduleUrl": "git+https://github.com/Stuk/jszip.git",
|
||||||
"moduleVersion": "3.10.1",
|
"moduleVersion": "3.10.1",
|
||||||
"moduleLicense": "(MIT OR GPL-3.0-or-later)",
|
"moduleLicense": "(MIT OR GPL-3.0-or-later)",
|
||||||
"moduleLicenseUrl": "git+https://github.com/Stuk/jszip.git"
|
"moduleLicenseUrl": "git+https://github.com/Stuk/jszip.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "license-report",
|
"moduleName": "license-report",
|
||||||
"moduleUrl": "git+https://github.com/kessler/license-report.git",
|
"moduleUrl": "git+https://github.com/kessler/license-report.git",
|
||||||
"moduleVersion": "6.8.0",
|
"moduleVersion": "6.8.0",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/kessler/license-report.git"
|
"moduleLicenseUrl": "git+https://github.com/kessler/license-report.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "pdf-lib",
|
"moduleName": "pdf-lib",
|
||||||
"moduleUrl": "git+https://github.com/Hopding/pdf-lib.git",
|
"moduleUrl": "git+https://github.com/Hopding/pdf-lib.git",
|
||||||
"moduleVersion": "1.17.1",
|
"moduleVersion": "1.17.1",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/Hopding/pdf-lib.git"
|
"moduleLicenseUrl": "git+https://github.com/Hopding/pdf-lib.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "pdfjs-dist",
|
"moduleName": "pdfjs-dist",
|
||||||
"moduleUrl": "git+https://github.com/mozilla/pdf.js.git",
|
"moduleUrl": "git+https://github.com/mozilla/pdf.js.git",
|
||||||
"moduleVersion": "5.4.149",
|
"moduleVersion": "5.4.149",
|
||||||
"moduleLicense": "Apache-2.0",
|
"moduleLicense": "Apache-2.0",
|
||||||
"moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git"
|
"moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "posthog-js",
|
"moduleName": "posthog-js",
|
||||||
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
|
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
|
||||||
"moduleVersion": "1.268.0",
|
"moduleVersion": "1.268.0",
|
||||||
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
|
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
|
||||||
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
|
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "react",
|
"moduleName": "react",
|
||||||
"moduleUrl": "git+https://github.com/facebook/react.git",
|
"moduleUrl": "git+https://github.com/facebook/react.git",
|
||||||
"moduleVersion": "19.1.1",
|
"moduleVersion": "19.1.1",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
|
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "react-dom",
|
"moduleName": "react-dom",
|
||||||
"moduleUrl": "git+https://github.com/facebook/react.git",
|
"moduleUrl": "git+https://github.com/facebook/react.git",
|
||||||
"moduleVersion": "19.1.1",
|
"moduleVersion": "19.1.1",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
|
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "react-i18next",
|
"moduleName": "react-i18next",
|
||||||
"moduleUrl": "git+https://github.com/i18next/react-i18next.git",
|
"moduleUrl": "git+https://github.com/i18next/react-i18next.git",
|
||||||
"moduleVersion": "15.7.3",
|
"moduleVersion": "15.7.3",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git"
|
"moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "react-router-dom",
|
"moduleName": "react-router-dom",
|
||||||
"moduleUrl": "git+https://github.com/remix-run/react-router.git",
|
"moduleUrl": "git+https://github.com/remix-run/react-router.git",
|
||||||
"moduleVersion": "7.9.1",
|
"moduleVersion": "7.9.1",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git"
|
"moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "tailwindcss",
|
"moduleName": "tailwindcss",
|
||||||
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
|
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
|
||||||
"moduleVersion": "4.1.13",
|
"moduleVersion": "4.1.13",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
|
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "web-vitals",
|
"moduleName": "web-vitals",
|
||||||
"moduleUrl": "git+https://github.com/GoogleChrome/web-vitals.git",
|
"moduleUrl": "git+https://github.com/GoogleChrome/web-vitals.git",
|
||||||
"moduleVersion": "5.1.0",
|
"moduleVersion": "5.1.0",
|
||||||
"moduleLicense": "Apache-2.0",
|
"moduleLicense": "Apache-2.0",
|
||||||
"moduleLicenseUrl": "git+https://github.com/GoogleChrome/web-vitals.git"
|
"moduleLicenseUrl": "git+https://github.com/GoogleChrome/web-vitals.git"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -21,9 +21,7 @@ import "@app/utils/fileIdSafety";
|
|||||||
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<PreferencesProvider>
|
<PreferencesProvider>
|
||||||
<RainbowThemeProvider>
|
<RainbowThemeProvider>{children}</RainbowThemeProvider>
|
||||||
{children}
|
|
||||||
</RainbowThemeProvider>
|
|
||||||
</PreferencesProvider>
|
</PreferencesProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from "react";
|
||||||
import { useBanner } from '@app/contexts/BannerContext';
|
import { useBanner } from "@app/contexts/BannerContext";
|
||||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
import NavigationWarningModal from "@app/components/shared/NavigationWarningModal";
|
||||||
|
|
||||||
interface AppLayoutProps {
|
interface AppLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -21,11 +21,9 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
|
||||||
{banner}
|
{banner}
|
||||||
<div style={{ flex: 1, minHeight: 0, height: 0 }}>
|
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<NavigationWarningModal />
|
<NavigationWarningModal />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
|
|||||||
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
|
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
|
||||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||||
import { PreferencesProvider, usePreferences } from "@app/contexts/PreferencesContext";
|
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 { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||||
@@ -20,8 +25,8 @@ import { BannerProvider } from "@app/contexts/BannerContext";
|
|||||||
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||||
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
||||||
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
||||||
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
|
import AppConfigLoader from "@app/components/shared/AppConfigLoader";
|
||||||
import { RedactionProvider } from "@app/contexts/RedactionContext";
|
import { RedactionProvider } from "@app/contexts/RedactionContext";
|
||||||
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
|
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
|
||||||
|
|
||||||
@@ -41,14 +46,14 @@ function BrandingAssetManager() {
|
|||||||
const { favicon, logo192, manifestHref } = useLogoAssets();
|
const { favicon, logo192, manifestHref } = useLogoAssets();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const setLinkHref = (selector: string, href: string) => {
|
const setLinkHref = (selector: string, href: string) => {
|
||||||
const link = document.querySelector<HTMLLinkElement>(selector);
|
const link = document.querySelector<HTMLLinkElement>(selector);
|
||||||
if (link && link.getAttribute('href') !== href) {
|
if (link && link.getAttribute("href") !== href) {
|
||||||
link.setAttribute('href', href);
|
link.setAttribute("href", href);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,7 +67,7 @@ function BrandingAssetManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Avoid requirement to have props which are required in app providers anyway
|
// Avoid requirement to have props which are required in app providers anyway
|
||||||
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, 'children' | 'retryOptions'>;
|
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, "children" | "retryOptions">;
|
||||||
|
|
||||||
export interface AppProvidersProps {
|
export interface AppProvidersProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -98,49 +103,44 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
|||||||
<RainbowThemeProvider>
|
<RainbowThemeProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<BannerProvider>
|
<BannerProvider>
|
||||||
<AppConfigProvider
|
<AppConfigProvider retryOptions={appConfigRetryOptions} {...appConfigProviderProps}>
|
||||||
retryOptions={appConfigRetryOptions}
|
<ScarfTrackingInitializer />
|
||||||
{...appConfigProviderProps}
|
<AppConfigLoader />
|
||||||
>
|
<ServerDefaultsSync />
|
||||||
<ScarfTrackingInitializer />
|
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||||
<AppConfigLoader />
|
<AppInitializer />
|
||||||
<ServerDefaultsSync />
|
<BrandingAssetManager />
|
||||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
<ToolRegistryProvider>
|
||||||
<AppInitializer />
|
<NavigationProvider>
|
||||||
<BrandingAssetManager />
|
<FilesModalProvider>
|
||||||
<ToolRegistryProvider>
|
<ToolWorkflowProvider>
|
||||||
<NavigationProvider>
|
<HotkeyProvider>
|
||||||
<FilesModalProvider>
|
<SidebarProvider>
|
||||||
<ToolWorkflowProvider>
|
<ViewerProvider>
|
||||||
<HotkeyProvider>
|
<PageEditorProvider>
|
||||||
<SidebarProvider>
|
<SignatureProvider>
|
||||||
<ViewerProvider>
|
<RedactionProvider>
|
||||||
<PageEditorProvider>
|
<FormFillProvider>
|
||||||
<SignatureProvider>
|
|
||||||
<RedactionProvider>
|
|
||||||
<FormFillProvider>
|
|
||||||
<AnnotationProvider>
|
<AnnotationProvider>
|
||||||
<RightRailProvider>
|
<RightRailProvider>
|
||||||
<TourOrchestrationProvider>
|
<TourOrchestrationProvider>
|
||||||
<AdminTourOrchestrationProvider>
|
<AdminTourOrchestrationProvider>{children}</AdminTourOrchestrationProvider>
|
||||||
{children}
|
|
||||||
</AdminTourOrchestrationProvider>
|
|
||||||
</TourOrchestrationProvider>
|
</TourOrchestrationProvider>
|
||||||
</RightRailProvider>
|
</RightRailProvider>
|
||||||
</AnnotationProvider>
|
</AnnotationProvider>
|
||||||
</FormFillProvider>
|
</FormFillProvider>
|
||||||
</RedactionProvider>
|
</RedactionProvider>
|
||||||
</SignatureProvider>
|
</SignatureProvider>
|
||||||
</PageEditorProvider>
|
</PageEditorProvider>
|
||||||
</ViewerProvider>
|
</ViewerProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</HotkeyProvider>
|
</HotkeyProvider>
|
||||||
</ToolWorkflowProvider>
|
</ToolWorkflowProvider>
|
||||||
</FilesModalProvider>
|
</FilesModalProvider>
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
</ToolRegistryProvider>
|
</ToolRegistryProvider>
|
||||||
</FileContextProvider>
|
</FileContextProvider>
|
||||||
</AppConfigProvider>
|
</AppConfigProvider>
|
||||||
</BannerProvider>
|
</BannerProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</RainbowThemeProvider>
|
</RainbowThemeProvider>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
import { Modal } from '@mantine/core';
|
import { Modal } from "@mantine/core";
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from "@mantine/dropzone";
|
||||||
import { StirlingFileStub } from '@app/types/fileContext';
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import { useFileManager } from '@app/hooks/useFileManager';
|
import { useFileManager } from "@app/hooks/useFileManager";
|
||||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import { Tool } from '@app/types/tool';
|
import { Tool } from "@app/types/tool";
|
||||||
import MobileLayout from '@app/components/fileManager/MobileLayout';
|
import MobileLayout from "@app/components/fileManager/MobileLayout";
|
||||||
import DesktopLayout from '@app/components/fileManager/DesktopLayout';
|
import DesktopLayout from "@app/components/fileManager/DesktopLayout";
|
||||||
import DragOverlay from '@app/components/fileManager/DragOverlay';
|
import DragOverlay from "@app/components/fileManager/DragOverlay";
|
||||||
import { FileManagerProvider } from '@app/contexts/FileManagerContext';
|
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
|
||||||
import { Z_INDEX_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
import { Z_INDEX_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||||
import { isGoogleDriveConfigured, extractGoogleDriveBackendConfig } from '@app/services/googleDrivePickerService';
|
import { isGoogleDriveConfigured, extractGoogleDriveBackendConfig } from "@app/services/googleDrivePickerService";
|
||||||
import { loadScript } from '@app/utils/scriptLoader';
|
import { loadScript } from "@app/utils/scriptLoader";
|
||||||
import { useAllFiles } from '@app/contexts/FileContext';
|
import { useAllFiles } from "@app/contexts/FileContext";
|
||||||
|
|
||||||
interface FileManagerProps {
|
interface FileManagerProps {
|
||||||
selectedTool?: Tool | null;
|
selectedTool?: Tool | null;
|
||||||
@@ -32,47 +32,59 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
const { fileIds: activeFileIds } = useAllFiles();
|
const { fileIds: activeFileIds } = useAllFiles();
|
||||||
|
|
||||||
// File management handlers
|
// File management handlers
|
||||||
const isFileSupported = useCallback((fileName: string) => {
|
const isFileSupported = useCallback(
|
||||||
if (!selectedTool?.supportedFormats) return true;
|
(fileName: string) => {
|
||||||
const extension = fileName.split('.').pop()?.toLowerCase();
|
if (!selectedTool?.supportedFormats) return true;
|
||||||
return selectedTool.supportedFormats.includes(extension || '');
|
const extension = fileName.split(".").pop()?.toLowerCase();
|
||||||
}, [selectedTool?.supportedFormats]);
|
return selectedTool.supportedFormats.includes(extension || "");
|
||||||
|
},
|
||||||
|
[selectedTool?.supportedFormats],
|
||||||
|
);
|
||||||
|
|
||||||
const refreshRecentFiles = useCallback(async () => {
|
const refreshRecentFiles = useCallback(async () => {
|
||||||
const files = await loadRecentFiles();
|
const files = await loadRecentFiles();
|
||||||
setRecentFiles(files);
|
setRecentFiles(files);
|
||||||
}, [loadRecentFiles]);
|
}, [loadRecentFiles]);
|
||||||
|
|
||||||
const handleRecentFilesSelected = useCallback(async (files: StirlingFileStub[]) => {
|
const handleRecentFilesSelected = useCallback(
|
||||||
try {
|
async (files: StirlingFileStub[]) => {
|
||||||
// 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) {
|
|
||||||
try {
|
try {
|
||||||
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
|
// Use StirlingFileStubs directly - preserves all metadata!
|
||||||
onFileUpload(files);
|
onRecentFileSelect(files);
|
||||||
await refreshRecentFiles();
|
|
||||||
} catch (error) {
|
} 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) => {
|
const handleNewFileUpload = useCallback(
|
||||||
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
async (files: File[]) => {
|
||||||
}, [handleRemoveFile, recentFiles]);
|
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(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
|
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
|
||||||
checkMobile();
|
checkMobile();
|
||||||
window.addEventListener('resize', checkMobile);
|
window.addEventListener("resize", checkMobile);
|
||||||
return () => window.removeEventListener('resize', checkMobile);
|
return () => window.removeEventListener("resize", checkMobile);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,7 +101,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
// StoredFileMetadata doesn't have blob URLs, so no cleanup needed
|
// StoredFileMetadata doesn't have blob URLs, so no cleanup needed
|
||||||
// Blob URLs are managed by FileContext and tool operations
|
// Blob URLs are managed by FileContext and tool operations
|
||||||
console.log('FileManager unmounting - FileContext handles blob URL cleanup');
|
console.log("FileManager unmounting - FileContext handles blob URL cleanup");
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -97,7 +109,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
// Use useMemo to only track Google Drive config changes, not all config updates
|
// Use useMemo to only track Google Drive config changes, not all config updates
|
||||||
const googleDriveBackendConfig = useMemo(
|
const googleDriveBackendConfig = useMemo(
|
||||||
() => extractGoogleDriveBackendConfig(config),
|
() => extractGoogleDriveBackendConfig(config),
|
||||||
[config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId]
|
[config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,29 +117,29 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
// Load scripts in parallel without blocking
|
// Load scripts in parallel without blocking
|
||||||
Promise.all([
|
Promise.all([
|
||||||
loadScript({
|
loadScript({
|
||||||
src: 'https://apis.google.com/js/api.js',
|
src: "https://apis.google.com/js/api.js",
|
||||||
id: 'gapi-script',
|
id: "gapi-script",
|
||||||
async: true,
|
async: true,
|
||||||
defer: true,
|
defer: true,
|
||||||
}),
|
}),
|
||||||
loadScript({
|
loadScript({
|
||||||
src: 'https://accounts.google.com/gsi/client',
|
src: "https://accounts.google.com/gsi/client",
|
||||||
id: 'gis-script',
|
id: "gis-script",
|
||||||
async: true,
|
async: true,
|
||||||
defer: true,
|
defer: true,
|
||||||
}),
|
}),
|
||||||
]).catch((error) => {
|
]).catch((error) => {
|
||||||
console.warn('Failed to preload Google Drive scripts:', error);
|
console.warn("Failed to preload Google Drive scripts:", error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [googleDriveBackendConfig]);
|
}, [googleDriveBackendConfig]);
|
||||||
|
|
||||||
// Modal size constants for consistent scaling
|
// Modal size constants for consistent scaling
|
||||||
const modalHeight = '80vh';
|
const modalHeight = "80vh";
|
||||||
const modalWidth = isMobile ? '100%' : '80vw';
|
const modalWidth = isMobile ? "100%" : "80vw";
|
||||||
const modalMaxWidth = isMobile ? '100%' : '1200px';
|
const modalMaxWidth = isMobile ? "100%" : "1200px";
|
||||||
const modalMaxHeight = '1200px';
|
const modalMaxHeight = "1200px";
|
||||||
const modalMinWidth = isMobile ? '320px' : '800px';
|
const modalMinWidth = isMobile ? "320px" : "800px";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -141,23 +153,25 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
zIndex={Z_INDEX_FILE_MANAGER_MODAL}
|
zIndex={Z_INDEX_FILE_MANAGER_MODAL}
|
||||||
styles={{
|
styles={{
|
||||||
content: {
|
content: {
|
||||||
position: 'relative',
|
position: "relative",
|
||||||
margin: isMobile ? '1rem' : '2rem'
|
margin: isMobile ? "1rem" : "2rem",
|
||||||
},
|
},
|
||||||
body: { padding: 0 },
|
body: { padding: 0 },
|
||||||
header: { display: 'none' }
|
header: { display: "none" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div
|
||||||
position: 'relative',
|
style={{
|
||||||
height: modalHeight,
|
position: "relative",
|
||||||
width: modalWidth,
|
height: modalHeight,
|
||||||
maxWidth: modalMaxWidth,
|
width: modalWidth,
|
||||||
maxHeight: modalMaxHeight,
|
maxWidth: modalMaxWidth,
|
||||||
minWidth: modalMinWidth,
|
maxHeight: modalMaxHeight,
|
||||||
margin: '0 auto',
|
minWidth: modalMinWidth,
|
||||||
overflow: 'hidden'
|
margin: "0 auto",
|
||||||
}}>
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={handleNewFileUpload}
|
onDrop={handleNewFileUpload}
|
||||||
onDragEnter={() => setIsDragging(true)}
|
onDragEnter={() => setIsDragging(true)}
|
||||||
@@ -165,14 +179,14 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
multiple={true}
|
multiple={true}
|
||||||
activateOnClick={false}
|
activateOnClick={false}
|
||||||
style={{
|
style={{
|
||||||
height: '100%',
|
height: "100%",
|
||||||
width: '100%',
|
width: "100%",
|
||||||
border: 'none',
|
border: "none",
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: "var(--radius-md)",
|
||||||
backgroundColor: 'var(--bg-file-manager)'
|
backgroundColor: "var(--bg-file-manager)",
|
||||||
}}
|
}}
|
||||||
styles={{
|
styles={{
|
||||||
inner: { pointerEvents: 'all' }
|
inner: { pointerEvents: "all" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileManagerProvider
|
<FileManagerProvider
|
||||||
|
|||||||
@@ -14,12 +14,7 @@ interface StorageStatsCardProps {
|
|||||||
onReloadFiles: () => void;
|
onReloadFiles: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
|
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, filesCount, onClearAll, onReloadFiles }) => {
|
||||||
storageStats,
|
|
||||||
filesCount,
|
|
||||||
onClearAll,
|
|
||||||
onReloadFiles,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!storageStats) return null;
|
if (!storageStats) return null;
|
||||||
@@ -59,12 +54,7 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
|
|||||||
{t("fileManager.clearAll", "Clear All")}
|
{t("fileManager.clearAll", "Clear All")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button variant="light" color="blue" size="xs" onClick={onReloadFiles}>
|
||||||
variant="light"
|
|
||||||
color="blue"
|
|
||||||
size="xs"
|
|
||||||
onClick={onReloadFiles}
|
|
||||||
>
|
|
||||||
Reload Files
|
Reload Files
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useContext, ReactNode } from 'react';
|
import React, { createContext, useContext, ReactNode } from "react";
|
||||||
|
|
||||||
interface PDFAnnotationContextValue {
|
interface PDFAnnotationContextValue {
|
||||||
// Drawing mode management
|
// Drawing mode management
|
||||||
@@ -58,7 +58,7 @@ export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
|
|||||||
getImageData,
|
getImageData,
|
||||||
isPlacementMode,
|
isPlacementMode,
|
||||||
signatureConfig,
|
signatureConfig,
|
||||||
setSignatureConfig
|
setSignatureConfig,
|
||||||
}) => {
|
}) => {
|
||||||
const contextValue: PDFAnnotationContextValue = {
|
const contextValue: PDFAnnotationContextValue = {
|
||||||
activateDrawMode,
|
activateDrawMode,
|
||||||
@@ -72,20 +72,16 @@ export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
|
|||||||
getImageData,
|
getImageData,
|
||||||
isPlacementMode,
|
isPlacementMode,
|
||||||
signatureConfig,
|
signatureConfig,
|
||||||
setSignatureConfig
|
setSignatureConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <PDFAnnotationContext.Provider value={contextValue}>{children}</PDFAnnotationContext.Provider>;
|
||||||
<PDFAnnotationContext.Provider value={contextValue}>
|
|
||||||
{children}
|
|
||||||
</PDFAnnotationContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
|
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
|
||||||
const context = useContext(PDFAnnotationContext);
|
const context = useContext(PDFAnnotationContext);
|
||||||
if (context === undefined) {
|
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;
|
return context;
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from "react";
|
||||||
import { Stack, Alert, Text } from '@mantine/core';
|
import { Stack, Alert, Text } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { DrawingControls } from '@app/components/annotation/shared/DrawingControls';
|
import { DrawingControls } from "@app/components/annotation/shared/DrawingControls";
|
||||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
||||||
import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider';
|
import { usePDFAnnotation } from "@app/components/annotation/providers/PDFAnnotationProvider";
|
||||||
import { useSignature } from '@app/contexts/SignatureContext';
|
import { useSignature } from "@app/contexts/SignatureContext";
|
||||||
|
|
||||||
export interface AnnotationToolConfig {
|
export interface AnnotationToolConfig {
|
||||||
enableDrawing?: boolean;
|
enableDrawing?: boolean;
|
||||||
@@ -25,17 +25,13 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
|||||||
config,
|
config,
|
||||||
children,
|
children,
|
||||||
onSignatureDataChange,
|
onSignatureDataChange,
|
||||||
disabled = false
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
const { activateSignaturePlacementMode, undo, redo } = usePDFAnnotation();
|
||||||
activateSignaturePlacementMode,
|
|
||||||
undo,
|
|
||||||
redo
|
|
||||||
} = usePDFAnnotation();
|
|
||||||
const { historyApiRef } = useSignature();
|
const { historyApiRef } = useSignature();
|
||||||
|
|
||||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
const [selectedColor, setSelectedColor] = useState("#000000");
|
||||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||||
@@ -94,14 +90,12 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
|||||||
signatureData,
|
signatureData,
|
||||||
onSignatureDataChange: handleSignatureDataChange,
|
onSignatureDataChange: handleSignatureDataChange,
|
||||||
onColorSwatchClick: () => setIsColorPickerOpen(true),
|
onColorSwatchClick: () => setIsColorPickerOpen(true),
|
||||||
disabled
|
disabled,
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Instructions for placing signature */}
|
{/* Instructions for placing signature */}
|
||||||
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
|
<Alert color="blue" title={t("sign.instructions.title", "How to add signature")}>
|
||||||
<Text size="sm">
|
<Text size="sm">Click anywhere on the PDF to place your annotation.</Text>
|
||||||
Click anywhere on the PDF to place your annotation.
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{/* Color Picker Modal */}
|
{/* Color Picker Modal */}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from '@mantine/core';
|
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from "@mantine/core";
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import ColorizeIcon from '@mui/icons-material/Colorize';
|
import ColorizeIcon from "@mui/icons-material/Colorize";
|
||||||
|
|
||||||
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
|
// 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.
|
// 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 {
|
interface EyeDropper {
|
||||||
open(): Promise<{ sRGBHex: string }>;
|
open(): Promise<{ sRGBHex: string }>;
|
||||||
}
|
}
|
||||||
declare const EyeDropper: { new(): EyeDropper };
|
declare const EyeDropper: { new (): EyeDropper };
|
||||||
|
|
||||||
interface ColorControlProps {
|
interface ColorControlProps {
|
||||||
value: string;
|
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)
|
// Only propagate to the parent (which triggers expensive annotation updates)
|
||||||
// on onChangeEnd (mouse-up / swatch click), preventing infinite re-render loops.
|
// on onChangeEnd (mouse-up / swatch click), preventing infinite re-render loops.
|
||||||
const [localColor, setLocalColor] = useState(value);
|
const [localColor, setLocalColor] = useState(value);
|
||||||
useEffect(() => { setLocalColor(value); }, [value]);
|
useEffect(() => {
|
||||||
|
setLocalColor(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
const handleEyeDropper = useCallback(async () => {
|
const handleEyeDropper = useCallback(async () => {
|
||||||
if (!supportsEyeDropper) return;
|
if (!supportsEyeDropper) return;
|
||||||
@@ -50,13 +52,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
|
|||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
backgroundColor: 'var(--bg-raised)',
|
backgroundColor: "var(--bg-raised)",
|
||||||
border: '1px solid var(--border-default)',
|
border: "1px solid var(--border-default)",
|
||||||
color: 'var(--text-secondary)',
|
color: "var(--text-secondary)",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
backgroundColor: 'var(--hover-bg)',
|
backgroundColor: "var(--hover-bg)",
|
||||||
borderColor: 'var(--border-strong)',
|
borderColor: "var(--border-strong)",
|
||||||
color: 'var(--text-primary)',
|
color: "var(--text-primary)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -73,8 +75,16 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
|
|||||||
onChange={setLocalColor}
|
onChange={setLocalColor}
|
||||||
onChangeEnd={onChange}
|
onChangeEnd={onChange}
|
||||||
swatches={[
|
swatches={[
|
||||||
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff',
|
"#000000",
|
||||||
'#ffff00', '#ff00ff', '#00ffff', '#ffa500', 'transparent'
|
"#ffffff",
|
||||||
|
"#ff0000",
|
||||||
|
"#00ff00",
|
||||||
|
"#0000ff",
|
||||||
|
"#ffff00",
|
||||||
|
"#ff00ff",
|
||||||
|
"#00ffff",
|
||||||
|
"#ffa500",
|
||||||
|
"transparent",
|
||||||
]}
|
]}
|
||||||
swatchesPerRow={5}
|
swatchesPerRow={5}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -82,7 +92,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
|
|||||||
{supportsEyeDropper && (
|
{supportsEyeDropper && (
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Tooltip label="Pick colour from screen">
|
<Tooltip label="Pick colour from screen">
|
||||||
<ActionIcon variant="subtle" color="gray" size="sm" onClick={handleEyeDropper} style={{ color: 'var(--text-primary)' }}>
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleEyeDropper}
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
<ColorizeIcon style={{ fontSize: 16 }} />
|
<ColorizeIcon style={{ fontSize: 16 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from '@mantine/core';
|
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -26,48 +26,42 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|||||||
opacityLabel,
|
opacityLabel,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
|
const resolvedTitle = title ?? t("colorPicker.title", "Choose colour");
|
||||||
const resolvedOpacityLabel = opacityLabel ?? t('annotation.opacity', 'Opacity');
|
const resolvedOpacityLabel = opacityLabel ?? t("annotation.opacity", "Opacity");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal opened={isOpen} onClose={onClose} title={resolvedTitle} size="sm" centered>
|
||||||
opened={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={resolvedTitle}
|
|
||||||
size="sm"
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<MantineColorPicker
|
<MantineColorPicker
|
||||||
format="hex"
|
format="hex"
|
||||||
value={selectedColor}
|
value={selectedColor}
|
||||||
onChange={onColorChange}
|
onChange={onColorChange}
|
||||||
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
|
swatches={["#000000", "#0066cc", "#cc0000", "#cc6600", "#009900", "#6600cc"]}
|
||||||
swatchesPerRow={6}
|
swatchesPerRow={6}
|
||||||
size="lg"
|
size="lg"
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
{showOpacity && onOpacityChange && opacity !== undefined && (
|
{showOpacity && onOpacityChange && opacity !== undefined && (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="sm" fw={500}>{resolvedOpacityLabel}</Text>
|
<Text size="sm" fw={500}>
|
||||||
|
{resolvedOpacityLabel}
|
||||||
|
</Text>
|
||||||
<Slider
|
<Slider
|
||||||
min={10}
|
min={10}
|
||||||
max={100}
|
max={100}
|
||||||
value={opacity}
|
value={opacity}
|
||||||
onChange={onOpacityChange}
|
onChange={onOpacityChange}
|
||||||
marks={[
|
marks={[
|
||||||
{ value: 25, label: '25%' },
|
{ value: 25, label: "25%" },
|
||||||
{ value: 50, label: '50%' },
|
{ value: 50, label: "50%" },
|
||||||
{ value: 75, label: '75%' },
|
{ value: 75, label: "75%" },
|
||||||
{ value: 100, label: '100%' },
|
{ value: 100, label: "100%" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button onClick={onClose}>
|
<Button onClick={onClose}>{t("common.done", "Done")}</Button>
|
||||||
{t('common.done', 'Done')}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -80,18 +74,6 @@ interface ColorSwatchButtonProps {
|
|||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
|
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({ color, onClick, size = 24 }) => {
|
||||||
color,
|
return <ColorSwatch color={color} size={size} radius={0} style={{ cursor: "pointer" }} onClick={onClick} />;
|
||||||
onClick,
|
|
||||||
size = 24
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<ColorSwatch
|
|
||||||
color={color}
|
|
||||||
size={size}
|
|
||||||
radius={0}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={onClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core';
|
import { Paper, Button, Modal, Stack, Text, Group } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
|
import { ColorSwatchButton } from "@app/components/annotation/shared/ColorPicker";
|
||||||
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
|
import PenSizeSelector from "@app/components/tools/sign/PenSizeSelector";
|
||||||
import SignaturePad from 'signature_pad';
|
import SignaturePad from "signature_pad";
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||||
|
|
||||||
interface DrawingCanvasProps {
|
interface DrawingCanvasProps {
|
||||||
selectedColor: string;
|
selectedColor: string;
|
||||||
@@ -68,7 +68,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
if (savedSignatureData) {
|
if (savedSignatureData) {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
}
|
}
|
||||||
@@ -92,13 +92,16 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
}, [autoOpen]);
|
}, [autoOpen]);
|
||||||
|
|
||||||
const trimCanvas = (canvas: HTMLCanvasElement): string => {
|
const trimCanvas = (canvas: HTMLCanvasElement): string => {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return canvas.toDataURL('image/png');
|
if (!ctx) return canvas.toDataURL("image/png");
|
||||||
|
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
const pixels = imageData.data;
|
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
|
// Find bounds of non-transparent pixels
|
||||||
for (let y = 0; y < canvas.height; y++) {
|
for (let y = 0; y < canvas.height; y++) {
|
||||||
@@ -117,21 +120,21 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
const trimHeight = maxY - minY + 1;
|
const trimHeight = maxY - minY + 1;
|
||||||
|
|
||||||
// Create trimmed canvas
|
// Create trimmed canvas
|
||||||
const trimmedCanvas = document.createElement('canvas');
|
const trimmedCanvas = document.createElement("canvas");
|
||||||
trimmedCanvas.width = trimWidth;
|
trimmedCanvas.width = trimWidth;
|
||||||
trimmedCanvas.height = trimHeight;
|
trimmedCanvas.height = trimHeight;
|
||||||
const trimmedCtx = trimmedCanvas.getContext('2d');
|
const trimmedCtx = trimmedCanvas.getContext("2d");
|
||||||
if (trimmedCtx) {
|
if (trimmedCtx) {
|
||||||
trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
|
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 renderPreview = (dataUrl: string) => {
|
||||||
const canvas = previewCanvasRef.current;
|
const canvas = previewCanvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -153,7 +156,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
const canvas = modalCanvasRef.current;
|
const canvas = modalCanvasRef.current;
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
const trimmedPng = trimCanvas(canvas);
|
const trimmedPng = trimCanvas(canvas);
|
||||||
const untrimmedPng = canvas.toDataURL('image/png');
|
const untrimmedPng = canvas.toDataURL("image/png");
|
||||||
setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration
|
setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration
|
||||||
onSignatureDataChange(trimmedPng);
|
onSignatureDataChange(trimmedPng);
|
||||||
renderPreview(trimmedPng);
|
renderPreview(trimmedPng);
|
||||||
@@ -176,7 +179,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
padRef.current.clear();
|
padRef.current.clear();
|
||||||
}
|
}
|
||||||
if (previewCanvasRef.current) {
|
if (previewCanvasRef.current) {
|
||||||
const ctx = previewCanvasRef.current.getContext('2d');
|
const ctx = previewCanvasRef.current.getContext("2d");
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||||
}
|
}
|
||||||
@@ -209,7 +212,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = previewCanvasRef.current;
|
const canvas = previewCanvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
if (!initialSignatureData) {
|
if (!initialSignatureData) {
|
||||||
@@ -227,42 +230,45 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
<Paper withBorder p="md">
|
<Paper withBorder p="md">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<PrivateContent>
|
<PrivateContent>
|
||||||
<Text fw={500}>{t('sign.canvas.heading', 'Draw your signature')}</Text>
|
<Text fw={500}>{t("sign.canvas.heading", "Draw your signature")}</Text>
|
||||||
<canvas
|
<canvas
|
||||||
ref={previewCanvasRef}
|
ref={previewCanvasRef}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid #ccc',
|
border: "1px solid #ccc",
|
||||||
borderRadius: '4px',
|
borderRadius: "4px",
|
||||||
cursor: disabled ? 'default' : 'pointer',
|
cursor: disabled ? "default" : "pointer",
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: "#ffffff",
|
||||||
width: '100%',
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
onClick={disabled ? undefined : openModal}
|
onClick={disabled ? undefined : openModal}
|
||||||
/>
|
/>
|
||||||
</PrivateContent>
|
</PrivateContent>
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
{t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')}
|
{t("sign.canvas.clickToOpen", "Click to open the drawing canvas")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Modal opened={modalOpen} onClose={closeModal} title={t('sign.canvas.modalTitle', 'Draw your signature')} size="auto" centered>
|
<Modal
|
||||||
|
opened={modalOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
title={t("sign.canvas.modalTitle", "Draw your signature")}
|
||||||
|
size="auto"
|
||||||
|
centered
|
||||||
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group gap="lg" align="flex-end" wrap="wrap">
|
<Group gap="lg" align="flex-end" wrap="wrap">
|
||||||
<Stack gap={4} style={{ minWidth: 120 }}>
|
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{t('sign.canvas.colorLabel', 'Colour')}
|
{t("sign.canvas.colorLabel", "Colour")}
|
||||||
</Text>
|
</Text>
|
||||||
<ColorSwatchButton
|
<ColorSwatchButton color={selectedColor} onClick={onColorSwatchClick} />
|
||||||
color={selectedColor}
|
|
||||||
onClick={onColorSwatchClick}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={4} style={{ minWidth: 120 }}>
|
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{t('sign.canvas.penSizeLabel', 'Pen size')}
|
{t("sign.canvas.penSizeLabel", "Pen size")}
|
||||||
</Text>
|
</Text>
|
||||||
<PenSizeSelector
|
<PenSizeSelector
|
||||||
value={penSize}
|
value={penSize}
|
||||||
@@ -272,9 +278,9 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
updatePenSize(size);
|
updatePenSize(size);
|
||||||
}}
|
}}
|
||||||
onInputChange={onPenSizeInputChange}
|
onInputChange={onPenSizeInputChange}
|
||||||
placeholder={t('sign.canvas.penSizePlaceholder', 'Size')}
|
placeholder={t("sign.canvas.penSizePlaceholder", "Size")}
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
style={{ width: '80px' }}
|
style={{ width: "80px" }}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -286,26 +292,24 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
if (el) initPad(el);
|
if (el) initPad(el);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid #ccc',
|
border: "1px solid #ccc",
|
||||||
borderRadius: '4px',
|
borderRadius: "4px",
|
||||||
display: 'block',
|
display: "block",
|
||||||
touchAction: 'none',
|
touchAction: "none",
|
||||||
backgroundColor: 'white',
|
backgroundColor: "white",
|
||||||
width: '100%',
|
width: "100%",
|
||||||
maxWidth: '50rem',
|
maxWidth: "50rem",
|
||||||
height: '25rem',
|
height: "25rem",
|
||||||
cursor: 'crosshair',
|
cursor: "crosshair",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PrivateContent>
|
</PrivateContent>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
<Button variant="subtle" color="red" onClick={clear}>
|
<Button variant="subtle" color="red" onClick={clear}>
|
||||||
{t('sign.canvas.clear', 'Clear canvas')}
|
{t("sign.canvas.clear", "Clear canvas")}
|
||||||
</Button>
|
|
||||||
<Button onClick={closeModal}>
|
|
||||||
{t('common.done', 'Done')}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={closeModal}>{t("common.done", "Done")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Group, Button, ActionIcon, Tooltip } from '@mantine/core';
|
import { Group, Button, ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||||
|
|
||||||
interface DrawingControlsProps {
|
interface DrawingControlsProps {
|
||||||
onUndo?: () => void;
|
onUndo?: () => void;
|
||||||
@@ -35,30 +35,30 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Group gap="xs" wrap="nowrap" align="center">
|
<Group gap="xs" wrap="nowrap" align="center">
|
||||||
{onUndo && (
|
{onUndo && (
|
||||||
<Tooltip label={t('sign.undo', 'Undo')}>
|
<Tooltip label={t("sign.undo", "Undo")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t('sign.undo', 'Undo')}
|
aria-label={t("sign.undo", "Undo")}
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
disabled={undoDisabled}
|
disabled={undoDisabled}
|
||||||
color={undoDisabled ? 'gray' : 'blue'}
|
color={undoDisabled ? "gray" : "blue"}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="undo" width={20} height={20} style={{ color: 'currentColor' }} />
|
<LocalIcon icon="undo" width={20} height={20} style={{ color: "currentColor" }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{onRedo && (
|
{onRedo && (
|
||||||
<Tooltip label={t('sign.redo', 'Redo')}>
|
<Tooltip label={t("sign.redo", "Redo")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t('sign.redo', 'Redo')}
|
aria-label={t("sign.redo", "Redo")}
|
||||||
onClick={onRedo}
|
onClick={onRedo}
|
||||||
disabled={redoDisabled}
|
disabled={redoDisabled}
|
||||||
color={redoDisabled ? 'gray' : 'blue'}
|
color={redoDisabled ? "gray" : "blue"}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="redo" width={20} height={20} style={{ color: 'currentColor' }} />
|
<LocalIcon icon="redo" width={20} height={20} style={{ color: "currentColor" }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -67,13 +67,7 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
|||||||
|
|
||||||
{/* Place Signature Button */}
|
{/* Place Signature Button */}
|
||||||
{showPlaceButton && onPlaceSignature && (
|
{showPlaceButton && onPlaceSignature && (
|
||||||
<Button
|
<Button variant="filled" color="blue" onClick={onPlaceSignature} disabled={disabled || !hasSignatureData} ml="auto">
|
||||||
variant="filled"
|
|
||||||
color="blue"
|
|
||||||
onClick={onPlaceSignature}
|
|
||||||
disabled={disabled || !hasSignatureData}
|
|
||||||
ml="auto"
|
|
||||||
>
|
|
||||||
{placeButtonText}
|
{placeButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { FileInput, Text, Stack, Checkbox } from '@mantine/core';
|
import { FileInput, Text, Stack, Checkbox } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||||
import { removeWhiteBackground } from '@app/utils/imageTransparency';
|
import { removeWhiteBackground } from "@app/utils/imageTransparency";
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from "@app/components/toast";
|
||||||
|
|
||||||
interface ImageUploaderProps {
|
interface ImageUploaderProps {
|
||||||
onImageChange: (file: File | null) => void;
|
onImageChange: (file: File | null) => void;
|
||||||
@@ -22,7 +22,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
placeholder,
|
placeholder,
|
||||||
hint,
|
hint,
|
||||||
allowBackgroundRemoval = false,
|
allowBackgroundRemoval = false,
|
||||||
onProcessedImageData
|
onProcessedImageData,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [removeBackground, setRemoveBackground] = useState(false);
|
const [removeBackground, setRemoveBackground] = useState(false);
|
||||||
@@ -36,15 +36,18 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
try {
|
try {
|
||||||
const transparentImageDataUrl = await removeWhiteBackground(imageSource, {
|
const transparentImageDataUrl = await removeWhiteBackground(imageSource, {
|
||||||
autoDetectCorner: true,
|
autoDetectCorner: true,
|
||||||
tolerance: 15
|
tolerance: 15,
|
||||||
});
|
});
|
||||||
onProcessedImageData?.(transparentImageDataUrl);
|
onProcessedImageData?.(transparentImageDataUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error removing background:', error);
|
console.error("Error removing background:", error);
|
||||||
alert({
|
alert({
|
||||||
title: t('sign.image.backgroundRemovalFailedTitle', 'Background removal failed'),
|
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.'),
|
body: t(
|
||||||
alertType: 'error'
|
"sign.image.backgroundRemovalFailedMessage",
|
||||||
|
"Could not remove the background from the image. Using original image instead.",
|
||||||
|
),
|
||||||
|
alertType: "error",
|
||||||
});
|
});
|
||||||
onProcessedImageData?.(null);
|
onProcessedImageData?.(null);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -52,7 +55,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// When background removal is disabled, return the original image data
|
// When background removal is disabled, return the original image data
|
||||||
if (typeof imageSource === 'string') {
|
if (typeof imageSource === "string") {
|
||||||
onProcessedImageData?.(imageSource);
|
onProcessedImageData?.(imageSource);
|
||||||
} else {
|
} else {
|
||||||
// Convert File to data URL if needed
|
// Convert File to data URL if needed
|
||||||
@@ -69,8 +72,8 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
if (file && !disabled) {
|
if (file && !disabled) {
|
||||||
try {
|
try {
|
||||||
// Validate that it's actually an image file or SVG
|
// Validate that it's actually an image file or SVG
|
||||||
if (!file.type.startsWith('image/') && !file.name.toLowerCase().endsWith('.svg')) {
|
if (!file.type.startsWith("image/") && !file.name.toLowerCase().endsWith(".svg")) {
|
||||||
console.error('Selected file is not an image or SVG');
|
console.error("Selected file is not an image or SVG");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +83,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
let dataUrlToProcess: string;
|
let dataUrlToProcess: string;
|
||||||
|
|
||||||
// Check if file is SVG
|
// 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) {
|
if (isSvg) {
|
||||||
// For SVG, convert to PNG so it can be embedded in PDF
|
// For SVG, convert to PNG so it can be embedded in PDF
|
||||||
@@ -98,7 +101,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
setOriginalImageData(dataUrlToProcess);
|
setOriginalImageData(dataUrlToProcess);
|
||||||
await processImage(dataUrlToProcess, removeBackground);
|
await processImage(dataUrlToProcess, removeBackground);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing image file:', error);
|
console.error("Error processing image file:", error);
|
||||||
}
|
}
|
||||||
} else if (!file) {
|
} else if (!file) {
|
||||||
// Clear image data when no file is selected
|
// Clear image data when no file is selected
|
||||||
@@ -119,18 +122,18 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
|
|
||||||
// Parse SVG to get dimensions
|
// Parse SVG to get dimensions
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const svgDoc = parser.parseFromString(svgText, 'image/svg+xml');
|
const svgDoc = parser.parseFromString(svgText, "image/svg+xml");
|
||||||
const svgElement = svgDoc.documentElement;
|
const svgElement = svgDoc.documentElement;
|
||||||
|
|
||||||
// Get SVG dimensions
|
// Get SVG dimensions
|
||||||
let width = 800; // Default width
|
let width = 800; // Default width
|
||||||
let height = 600; // Default height
|
let height = 600; // Default height
|
||||||
|
|
||||||
if (svgElement.hasAttribute('width') && svgElement.hasAttribute('height')) {
|
if (svgElement.hasAttribute("width") && svgElement.hasAttribute("height")) {
|
||||||
width = parseFloat(svgElement.getAttribute('width') || '800');
|
width = parseFloat(svgElement.getAttribute("width") || "800");
|
||||||
height = parseFloat(svgElement.getAttribute('height') || '600');
|
height = parseFloat(svgElement.getAttribute("height") || "600");
|
||||||
} else if (svgElement.hasAttribute('viewBox')) {
|
} else if (svgElement.hasAttribute("viewBox")) {
|
||||||
const viewBox = svgElement.getAttribute('viewBox')?.split(/\s+|,/);
|
const viewBox = svgElement.getAttribute("viewBox")?.split(/\s+|,/);
|
||||||
if (viewBox && viewBox.length === 4) {
|
if (viewBox && viewBox.length === 4) {
|
||||||
width = parseFloat(viewBox[2]);
|
width = parseFloat(viewBox[2]);
|
||||||
height = parseFloat(viewBox[3]);
|
height = parseFloat(viewBox[3]);
|
||||||
@@ -151,11 +154,11 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
height *= 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
|
// Create an image element to render SVG
|
||||||
const img = new Image();
|
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);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
@@ -164,22 +167,27 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
const finalWidth = img.naturalWidth || img.width || width;
|
const finalWidth = img.naturalWidth || img.width || width;
|
||||||
const finalHeight = img.naturalHeight || img.height || height;
|
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
|
// Create canvas to convert to PNG
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = finalWidth;
|
canvas.width = finalWidth;
|
||||||
canvas.height = finalHeight;
|
canvas.height = finalHeight;
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
reject(new Error('Failed to get canvas context'));
|
reject(new Error("Failed to get canvas context"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill with white background (optional, for transparency support)
|
// Fill with white background (optional, for transparency support)
|
||||||
ctx.fillStyle = 'white';
|
ctx.fillStyle = "white";
|
||||||
ctx.fillRect(0, 0, finalWidth, finalHeight);
|
ctx.fillRect(0, 0, finalWidth, finalHeight);
|
||||||
|
|
||||||
// Draw SVG
|
// Draw SVG
|
||||||
@@ -187,31 +195,31 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
// Convert canvas to PNG data URL
|
// Convert canvas to PNG data URL
|
||||||
const pngDataUrl = canvas.toDataURL('image/png');
|
const pngDataUrl = canvas.toDataURL("image/png");
|
||||||
console.log('SVG converted to PNG successfully');
|
console.log("SVG converted to PNG successfully");
|
||||||
resolve(pngDataUrl);
|
resolve(pngDataUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
console.error('Error during canvas rendering:', error);
|
console.error("Error during canvas rendering:", error);
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
img.onerror = (error) => {
|
img.onerror = (error) => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
console.error('Failed to load SVG image:', error);
|
console.error("Failed to load SVG image:", error);
|
||||||
reject(new Error('Failed to load SVG image'));
|
reject(new Error("Failed to load SVG image"));
|
||||||
};
|
};
|
||||||
|
|
||||||
img.src = url;
|
img.src = url;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing SVG:', error);
|
console.error("Error parsing SVG:", error);
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.onerror = () => {
|
reader.onerror = () => {
|
||||||
console.error('Error reading file:', reader.error);
|
console.error("Error reading file:", reader.error);
|
||||||
reject(reader.error);
|
reject(reader.error);
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
@@ -231,7 +239,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
<PrivateContent>
|
<PrivateContent>
|
||||||
<FileInput
|
<FileInput
|
||||||
label={label}
|
label={label}
|
||||||
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
|
placeholder={placeholder || t("sign.image.placeholder", "Select image file")}
|
||||||
accept="image/*,.svg"
|
accept="image/*,.svg"
|
||||||
onChange={handleImageChange}
|
onChange={handleImageChange}
|
||||||
disabled={disabled || isProcessing}
|
disabled={disabled || isProcessing}
|
||||||
@@ -239,7 +247,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
</PrivateContent>
|
</PrivateContent>
|
||||||
{allowBackgroundRemoval && (
|
{allowBackgroundRemoval && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={t('sign.image.removeBackground', 'Remove white background (make transparent)')}
|
label={t("sign.image.removeBackground", "Remove white background (make transparent)")}
|
||||||
checked={removeBackground}
|
checked={removeBackground}
|
||||||
onChange={(event) => handleBackgroundRemovalChange(event.currentTarget.checked)}
|
onChange={(event) => handleBackgroundRemovalChange(event.currentTarget.checked)}
|
||||||
disabled={disabled || !currentFile || isProcessing}
|
disabled={disabled || !currentFile || isProcessing}
|
||||||
@@ -252,7 +260,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
)}
|
)}
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t('sign.image.processing', 'Processing image...')}
|
{t("sign.image.processing", "Processing image...")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
|
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import OpacityIcon from '@mui/icons-material/Opacity';
|
import OpacityIcon from "@mui/icons-material/Opacity";
|
||||||
|
|
||||||
interface OpacityControlProps {
|
interface OpacityControlProps {
|
||||||
value: number; // 0-100
|
value: number; // 0-100
|
||||||
@@ -16,7 +16,7 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
|
|||||||
return (
|
return (
|
||||||
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
|
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t('annotation.opacity', 'Opacity')}>
|
<Tooltip label={t("annotation.opacity", "Opacity")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -26,13 +26,13 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
|
|||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
backgroundColor: 'var(--bg-raised)',
|
backgroundColor: "var(--bg-raised)",
|
||||||
border: '1px solid var(--border-default)',
|
border: "1px solid var(--border-default)",
|
||||||
color: 'var(--text-secondary)',
|
color: "var(--text-secondary)",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
backgroundColor: 'var(--hover-bg)',
|
backgroundColor: "var(--hover-bg)",
|
||||||
borderColor: 'var(--border-strong)',
|
borderColor: "var(--border-strong)",
|
||||||
color: 'var(--text-primary)',
|
color: "var(--text-primary)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -44,15 +44,9 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
|
|||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<Stack gap="xs" style={{ minWidth: 150 }}>
|
<Stack gap="xs" style={{ minWidth: 150 }}>
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
{t('annotation.opacity', 'Opacity')}
|
{t("annotation.opacity", "Opacity")}
|
||||||
</Text>
|
</Text>
|
||||||
<Slider
|
<Slider value={value} onChange={onChange} min={10} max={100} label={(val) => `${val}%`} />
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
min={10}
|
|
||||||
max={100}
|
|
||||||
label={(val) => `${val}%`}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core';
|
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import type { TrackedAnnotation } from '@embedpdf/plugin-annotation';
|
import type { TrackedAnnotation } from "@embedpdf/plugin-annotation";
|
||||||
import type { PdfAnnotationObject } from '@embedpdf/models';
|
import type { PdfAnnotationObject } from "@embedpdf/models";
|
||||||
import type { AnnotationPatch } from '@app/components/viewer/viewerTypes';
|
import type { AnnotationPatch } from "@app/components/viewer/viewerTypes";
|
||||||
import TuneIcon from '@mui/icons-material/Tune';
|
import TuneIcon from "@mui/icons-material/Tune";
|
||||||
import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
|
import FormatAlignLeftIcon from "@mui/icons-material/FormatAlignLeft";
|
||||||
import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
|
import FormatAlignCenterIcon from "@mui/icons-material/FormatAlignCenter";
|
||||||
import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight';
|
import FormatAlignRightIcon from "@mui/icons-material/FormatAlignRight";
|
||||||
|
|
||||||
export type PropertiesAnnotationType = 'text' | 'note' | 'shape';
|
export type PropertiesAnnotationType = "text" | "note" | "shape";
|
||||||
|
|
||||||
interface PropertiesPopoverProps {
|
interface PropertiesPopoverProps {
|
||||||
annotationType: PropertiesAnnotationType;
|
annotationType: PropertiesAnnotationType;
|
||||||
@@ -18,12 +18,7 @@ interface PropertiesPopoverProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PropertiesPopover({
|
export function PropertiesPopover({ annotationType, annotation, onUpdate, disabled = false }: PropertiesPopoverProps) {
|
||||||
annotationType,
|
|
||||||
annotation,
|
|
||||||
onUpdate,
|
|
||||||
disabled = false,
|
|
||||||
}: PropertiesPopoverProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
|
|
||||||
@@ -41,17 +36,17 @@ export function PropertiesPopover({
|
|||||||
const fontSize = obj?.fontSize ?? 14;
|
const fontSize = obj?.fontSize ?? 14;
|
||||||
const textAlign = obj?.textAlign;
|
const textAlign = obj?.textAlign;
|
||||||
const currentAlign =
|
const currentAlign =
|
||||||
typeof textAlign === 'number'
|
typeof textAlign === "number"
|
||||||
? textAlign === 1
|
? textAlign === 1
|
||||||
? 'center'
|
? "center"
|
||||||
: textAlign === 2
|
: textAlign === 2
|
||||||
? 'right'
|
? "right"
|
||||||
: 'left'
|
: "left"
|
||||||
: textAlign === 'center'
|
: textAlign === "center"
|
||||||
? 'center'
|
? "center"
|
||||||
: textAlign === 'right'
|
: textAlign === "right"
|
||||||
? 'right'
|
? "right"
|
||||||
: 'left';
|
: "left";
|
||||||
|
|
||||||
// For shapes
|
// For shapes
|
||||||
const opacity = Math.round((obj?.opacity ?? 1) * 100);
|
const opacity = Math.round((obj?.opacity ?? 1) * 100);
|
||||||
@@ -63,7 +58,7 @@ export function PropertiesPopover({
|
|||||||
{/* Font Size */}
|
{/* Font Size */}
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" fw={500} mb={4}>
|
<Text size="xs" fw={500} mb={4}>
|
||||||
{t('annotation.fontSize', 'Font size')}
|
{t("annotation.fontSize", "Font size")}
|
||||||
</Text>
|
</Text>
|
||||||
<Slider
|
<Slider
|
||||||
value={fontSize}
|
value={fontSize}
|
||||||
@@ -77,7 +72,7 @@ export function PropertiesPopover({
|
|||||||
{/* Opacity */}
|
{/* Opacity */}
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" fw={500} mb={4}>
|
<Text size="xs" fw={500} mb={4}>
|
||||||
{t('annotation.opacity', 'Opacity')}
|
{t("annotation.opacity", "Opacity")}
|
||||||
</Text>
|
</Text>
|
||||||
<Slider
|
<Slider
|
||||||
value={Math.round((obj?.opacity ?? 1) * 100)}
|
value={Math.round((obj?.opacity ?? 1) * 100)}
|
||||||
@@ -91,25 +86,25 @@ export function PropertiesPopover({
|
|||||||
{/* Text Alignment */}
|
{/* Text Alignment */}
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" fw={500} mb={4}>
|
<Text size="xs" fw={500} mb={4}>
|
||||||
{t('annotation.textAlignment', 'Text Alignment')}
|
{t("annotation.textAlignment", "Text Alignment")}
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={currentAlign === 'left' ? 'filled' : 'default'}
|
variant={currentAlign === "left" ? "filled" : "default"}
|
||||||
onClick={() => onUpdate({ textAlign: 0 })}
|
onClick={() => onUpdate({ textAlign: 0 })}
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<FormatAlignLeftIcon style={{ fontSize: 18 }} />
|
<FormatAlignLeftIcon style={{ fontSize: 18 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={currentAlign === 'center' ? 'filled' : 'default'}
|
variant={currentAlign === "center" ? "filled" : "default"}
|
||||||
onClick={() => onUpdate({ textAlign: 1 })}
|
onClick={() => onUpdate({ textAlign: 1 })}
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<FormatAlignCenterIcon style={{ fontSize: 18 }} />
|
<FormatAlignCenterIcon style={{ fontSize: 18 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={currentAlign === 'right' ? 'filled' : 'default'}
|
variant={currentAlign === "right" ? "filled" : "default"}
|
||||||
onClick={() => onUpdate({ textAlign: 2 })}
|
onClick={() => onUpdate({ textAlign: 2 })}
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
@@ -125,7 +120,7 @@ export function PropertiesPopover({
|
|||||||
{/* Opacity */}
|
{/* Opacity */}
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" fw={500} mb={4}>
|
<Text size="xs" fw={500} mb={4}>
|
||||||
{t('annotation.opacity', 'Opacity')}
|
{t("annotation.opacity", "Opacity")}
|
||||||
</Text>
|
</Text>
|
||||||
<Slider
|
<Slider
|
||||||
value={opacity}
|
value={opacity}
|
||||||
@@ -148,7 +143,7 @@ export function PropertiesPopover({
|
|||||||
<Group gap="xs" align="flex-end">
|
<Group gap="xs" align="flex-end">
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Text size="xs" fw={500} mb={4}>
|
<Text size="xs" fw={500} mb={4}>
|
||||||
{t('annotation.strokeWidth', 'Stroke')}
|
{t("annotation.strokeWidth", "Stroke")}
|
||||||
</Text>
|
</Text>
|
||||||
<Slider
|
<Slider
|
||||||
value={strokeWidth}
|
value={strokeWidth}
|
||||||
@@ -166,7 +161,7 @@ export function PropertiesPopover({
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant={!borderVisible ? 'filled' : 'light'}
|
variant={!borderVisible ? "filled" : "light"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newValue = borderVisible ? 0 : 1;
|
const newValue = borderVisible ? 0 : 1;
|
||||||
onUpdate({
|
onUpdate({
|
||||||
@@ -176,9 +171,7 @@ export function PropertiesPopover({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{borderVisible
|
{borderVisible ? t("annotation.borderOn", "Border: On") : t("annotation.borderOff", "Border: Off")}
|
||||||
? t('annotation.borderOn', 'Border: On')
|
|
||||||
: t('annotation.borderOff', 'Border: Off')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +181,7 @@ export function PropertiesPopover({
|
|||||||
return (
|
return (
|
||||||
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow>
|
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t('annotation.properties', 'Properties')}>
|
<Tooltip label={t("annotation.properties", "Properties")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -198,13 +191,13 @@ export function PropertiesPopover({
|
|||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
backgroundColor: 'var(--bg-raised)',
|
backgroundColor: "var(--bg-raised)",
|
||||||
border: '1px solid var(--border-default)',
|
border: "1px solid var(--border-default)",
|
||||||
color: 'var(--text-secondary)',
|
color: "var(--text-secondary)",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
backgroundColor: 'var(--hover-bg)',
|
backgroundColor: "var(--hover-bg)",
|
||||||
borderColor: 'var(--border-strong)',
|
borderColor: "var(--border-strong)",
|
||||||
color: 'var(--text-primary)',
|
color: "var(--text-primary)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -214,8 +207,8 @@ export function PropertiesPopover({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
{(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()}
|
{(annotationType === "text" || annotationType === "note") && renderTextNoteControls()}
|
||||||
{annotationType === 'shape' && renderShapeControls()}
|
{annotationType === "shape" && renderShapeControls()}
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from "react";
|
||||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from '@mantine/core';
|
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
||||||
|
|
||||||
interface TextInputWithFontProps {
|
interface TextInputWithFontProps {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -12,8 +12,8 @@ interface TextInputWithFontProps {
|
|||||||
onFontFamilyChange: (family: string) => void;
|
onFontFamilyChange: (family: string) => void;
|
||||||
textColor?: string;
|
textColor?: string;
|
||||||
onTextColorChange?: (color: string) => void;
|
onTextColorChange?: (color: string) => void;
|
||||||
textAlign?: 'left' | 'center' | 'right';
|
textAlign?: "left" | "center" | "right";
|
||||||
onTextAlignChange?: (align: 'left' | 'center' | 'right') => void;
|
onTextAlignChange?: (align: "left" | "center" | "right") => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
@@ -31,9 +31,9 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
onFontSizeChange,
|
onFontSizeChange,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
onFontFamilyChange,
|
onFontFamilyChange,
|
||||||
textColor = '#000000',
|
textColor = "#000000",
|
||||||
onTextColorChange,
|
onTextColorChange,
|
||||||
textAlign = 'left',
|
textAlign = "left",
|
||||||
onTextAlignChange,
|
onTextAlignChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
label,
|
label,
|
||||||
@@ -42,7 +42,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
fontSizeLabel,
|
fontSizeLabel,
|
||||||
fontSizePlaceholder,
|
fontSizePlaceholder,
|
||||||
colorLabel,
|
colorLabel,
|
||||||
onAnyChange
|
onAnyChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||||
@@ -61,14 +61,37 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
}, [textColor]);
|
}, [textColor]);
|
||||||
|
|
||||||
const fontOptions = [
|
const fontOptions = [
|
||||||
{ value: 'Helvetica', label: 'Helvetica' },
|
{ value: "Helvetica", label: "Helvetica" },
|
||||||
{ value: 'Times-Roman', label: 'Times' },
|
{ value: "Times-Roman", label: "Times" },
|
||||||
{ value: 'Courier', label: 'Courier' },
|
{ value: "Courier", label: "Courier" },
|
||||||
{ value: 'Arial', label: 'Arial' },
|
{ value: "Arial", label: "Arial" },
|
||||||
{ value: 'Georgia', label: 'Georgia' },
|
{ 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
|
// Validate hex color
|
||||||
const isValidHexColor = (color: string): boolean => {
|
const isValidHexColor = (color: string): boolean => {
|
||||||
@@ -94,7 +117,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
label={fontLabel}
|
label={fontLabel}
|
||||||
value={fontFamily}
|
value={fontFamily}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
onFontFamilyChange(value || 'Helvetica');
|
onFontFamilyChange(value || "Helvetica");
|
||||||
onAnyChange?.();
|
onAnyChange?.();
|
||||||
}}
|
}}
|
||||||
data={fontOptions}
|
data={fontOptions}
|
||||||
@@ -187,7 +210,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
setColorInput(textColor);
|
setColorInput(textColor);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%' }}
|
style={{ width: "100%" }}
|
||||||
rightSection={
|
rightSection={
|
||||||
<Box
|
<Box
|
||||||
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
||||||
@@ -195,9 +218,9 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
backgroundColor: textColor,
|
backgroundColor: textColor,
|
||||||
border: '1px solid #ccc',
|
border: "1px solid #ccc",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
cursor: disabled ? 'default' : 'pointer'
|
cursor: disabled ? "default" : "pointer",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -224,14 +247,14 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
|||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={textAlign}
|
value={textAlign}
|
||||||
onChange={(value: string) => {
|
onChange={(value: string) => {
|
||||||
onTextAlignChange(value as 'left' | 'center' | 'right');
|
onTextAlignChange(value as "left" | "center" | "right");
|
||||||
onAnyChange?.();
|
onAnyChange?.();
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
data={[
|
data={[
|
||||||
{ label: t('textAlign.left', 'Left'), value: 'left' },
|
{ label: t("textAlign.left", "Left"), value: "left" },
|
||||||
{ label: t('textAlign.center', 'Center'), value: 'center' },
|
{ label: t("textAlign.center", "Center"), value: "center" },
|
||||||
{ label: t('textAlign.right', 'Right'), value: 'right' },
|
{ label: t("textAlign.right", "Right"), value: "right" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
|
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import LineWeightIcon from '@mui/icons-material/LineWeight';
|
import LineWeightIcon from "@mui/icons-material/LineWeight";
|
||||||
|
|
||||||
interface WidthControlProps {
|
interface WidthControlProps {
|
||||||
value: number;
|
value: number;
|
||||||
@@ -18,7 +18,7 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
|
|||||||
return (
|
return (
|
||||||
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
|
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t('annotation.width', 'Width')}>
|
<Tooltip label={t("annotation.width", "Width")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -28,13 +28,13 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
|
|||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
backgroundColor: 'var(--bg-raised)',
|
backgroundColor: "var(--bg-raised)",
|
||||||
border: '1px solid var(--border-default)',
|
border: "1px solid var(--border-default)",
|
||||||
color: 'var(--text-secondary)',
|
color: "var(--text-secondary)",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
backgroundColor: 'var(--hover-bg)',
|
backgroundColor: "var(--hover-bg)",
|
||||||
borderColor: 'var(--border-strong)',
|
borderColor: "var(--border-strong)",
|
||||||
color: 'var(--text-primary)',
|
color: "var(--text-primary)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -46,15 +46,9 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
|
|||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<Stack gap="xs" style={{ minWidth: 150 }}>
|
<Stack gap="xs" style={{ minWidth: 150 }}>
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
{t('annotation.width', 'Width')}
|
{t("annotation.width", "Width")}
|
||||||
</Text>
|
</Text>
|
||||||
<Slider
|
<Slider value={value} onChange={onChange} min={min} max={max} label={(val) => `${val}pt`} />
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
label={(val) => `${val}pt`}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -1,33 +1,26 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { Stack } from '@mantine/core';
|
import { Stack } from "@mantine/core";
|
||||||
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
|
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
|
||||||
import { DrawingCanvas } from '@app/components/annotation/shared/DrawingCanvas';
|
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
|
||||||
|
|
||||||
interface DrawingToolProps {
|
interface DrawingToolProps {
|
||||||
onDrawingChange?: (data: string | null) => void;
|
onDrawingChange?: (data: string | null) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DrawingTool: React.FC<DrawingToolProps> = ({
|
export const DrawingTool: React.FC<DrawingToolProps> = ({ onDrawingChange, disabled = false }) => {
|
||||||
onDrawingChange,
|
const [selectedColor] = useState("#000000");
|
||||||
disabled = false
|
|
||||||
}) => {
|
|
||||||
const [selectedColor] = useState('#000000');
|
|
||||||
const [penSize, setPenSize] = useState(2);
|
const [penSize, setPenSize] = useState(2);
|
||||||
const [penSizeInput, setPenSizeInput] = useState('2');
|
const [penSizeInput, setPenSizeInput] = useState("2");
|
||||||
|
|
||||||
const toolConfig = {
|
const toolConfig = {
|
||||||
enableDrawing: true,
|
enableDrawing: true,
|
||||||
showPlaceButton: true,
|
showPlaceButton: true,
|
||||||
placeButtonText: "Place Drawing"
|
placeButtonText: "Place Drawing",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseAnnotationTool
|
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onDrawingChange} disabled={disabled}>
|
||||||
config={toolConfig}
|
|
||||||
onSignatureDataChange={onDrawingChange}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<DrawingCanvas
|
<DrawingCanvas
|
||||||
selectedColor={selectedColor}
|
selectedColor={selectedColor}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { Stack } from '@mantine/core';
|
import { Stack } from "@mantine/core";
|
||||||
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
|
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
|
||||||
import { ImageUploader } from '@app/components/annotation/shared/ImageUploader';
|
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
|
||||||
|
|
||||||
interface ImageToolProps {
|
interface ImageToolProps {
|
||||||
onImageChange?: (data: string | null) => void;
|
onImageChange?: (data: string | null) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImageTool: React.FC<ImageToolProps> = ({
|
export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled = false }) => {
|
||||||
onImageChange,
|
|
||||||
disabled = false
|
|
||||||
}) => {
|
|
||||||
const [, setImageData] = useState<string | null>(null);
|
const [, setImageData] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleImageUpload = async (file: File | null) => {
|
const handleImageUpload = async (file: File | null) => {
|
||||||
@@ -23,7 +20,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
|
|||||||
if (e.target?.result) {
|
if (e.target?.result) {
|
||||||
resolve(e.target.result as string);
|
resolve(e.target.result as string);
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('Failed to read file'));
|
reject(new Error("Failed to read file"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.onerror = () => reject(reader.error);
|
reader.onerror = () => reject(reader.error);
|
||||||
@@ -33,7 +30,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
|
|||||||
setImageData(result);
|
setImageData(result);
|
||||||
onImageChange?.(result);
|
onImageChange?.(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading file:', error);
|
console.error("Error reading file:", error);
|
||||||
}
|
}
|
||||||
} else if (!file) {
|
} else if (!file) {
|
||||||
setImageData(null);
|
setImageData(null);
|
||||||
@@ -44,15 +41,11 @@ export const ImageTool: React.FC<ImageToolProps> = ({
|
|||||||
const toolConfig = {
|
const toolConfig = {
|
||||||
enableImageUpload: true,
|
enableImageUpload: true,
|
||||||
showPlaceButton: true,
|
showPlaceButton: true,
|
||||||
placeButtonText: "Place Image"
|
placeButtonText: "Place Image",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseAnnotationTool
|
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onImageChange} disabled={disabled}>
|
||||||
config={toolConfig}
|
|
||||||
onSignatureDataChange={onImageChange}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
onImageChange={handleImageUpload}
|
onImageChange={handleImageUpload}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from "react";
|
||||||
import { Button, Group, useMantineColorScheme } from '@mantine/core';
|
import { Button, Group, useMantineColorScheme } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
||||||
import styles from '@app/components/fileEditor/FileEditor.module.css';
|
import styles from "@app/components/fileEditor/FileEditor.module.css";
|
||||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||||
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
|
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
|
||||||
|
|
||||||
interface AddFileCardProps {
|
interface AddFileCardProps {
|
||||||
onFileSelect: (files: File[]) => void;
|
onFileSelect: (files: File[]) => void;
|
||||||
@@ -16,11 +16,7 @@ interface AddFileCardProps {
|
|||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddFileCard = ({
|
const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps) => {
|
||||||
onFileSelect,
|
|
||||||
accept,
|
|
||||||
multiple = true
|
|
||||||
}: AddFileCardProps) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { openFilesModal } = useFilesModalContext();
|
const { openFilesModal } = useFilesModalContext();
|
||||||
@@ -38,7 +34,7 @@ const AddFileCard = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const files = await openFilesFromDisk({
|
const files = await openFilesFromDisk({
|
||||||
multiple,
|
multiple,
|
||||||
onFallbackOpen: () => fileInputRef.current?.click()
|
onFallbackOpen: () => fileInputRef.current?.click(),
|
||||||
});
|
});
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
onFileSelect(files);
|
onFileSelect(files);
|
||||||
@@ -56,7 +52,7 @@ const AddFileCard = ({
|
|||||||
onFileSelect(files);
|
onFileSelect(files);
|
||||||
}
|
}
|
||||||
// Reset input so same files can be selected again
|
// Reset input so same files can be selected again
|
||||||
event.target.value = '';
|
event.target.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -67,17 +63,17 @@ const AddFileCard = ({
|
|||||||
accept={accept}
|
accept={accept}
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
style={{ display: 'none' }}
|
style={{ display: "none" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
|
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
aria-label={t('fileEditor.addFiles', 'Add files')}
|
aria-label={t("fileEditor.addFiles", "Add files")}
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleCardClick();
|
handleCardClick();
|
||||||
}
|
}
|
||||||
@@ -86,11 +82,9 @@ const AddFileCard = ({
|
|||||||
{/* Header bar - matches FileEditorThumbnail structure */}
|
{/* Header bar - matches FileEditorThumbnail structure */}
|
||||||
<div className={`${styles.header} ${styles.addFileHeader}`}>
|
<div className={`${styles.header} ${styles.addFileHeader}`}>
|
||||||
<div className={styles.logoMark}>
|
<div className={styles.logoMark}>
|
||||||
<AddIcon sx={{ color: 'inherit', fontSize: '1.5rem' }} />
|
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
|
||||||
</div>
|
|
||||||
<div className={styles.headerIndex}>
|
|
||||||
{t('fileEditor.addFiles', 'Add Files')}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.headerIndex}>{t("fileEditor.addFiles", "Add Files")}</div>
|
||||||
<div className={styles.kebab} />
|
<div className={styles.kebab} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,84 +93,81 @@ const AddFileCard = ({
|
|||||||
{/* Stirling PDF Branding */}
|
{/* Stirling PDF Branding */}
|
||||||
<Group gap="xs" align="center">
|
<Group gap="xs" align="center">
|
||||||
<img
|
<img
|
||||||
src={colorScheme === 'dark' ? wordmark.white : wordmark.grey}
|
src={colorScheme === "dark" ? wordmark.white : wordmark.grey}
|
||||||
alt="Stirling PDF"
|
alt="Stirling PDF"
|
||||||
style={{ height: '2.2rem', width: 'auto' }}
|
style={{ height: "2.2rem", width: "auto" }}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
|
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
gap: '0.6rem',
|
gap: "0.6rem",
|
||||||
width: '100%',
|
width: "100%",
|
||||||
marginTop: '0.8rem',
|
marginTop: "0.8rem",
|
||||||
marginBottom: '0.8rem'
|
marginBottom: "0.8rem",
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => setIsUploadHover(false)}
|
onMouseLeave={() => setIsUploadHover(false)}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--landing-button-bg)',
|
backgroundColor: "var(--landing-button-bg)",
|
||||||
color: 'var(--landing-button-color)',
|
color: "var(--landing-button-color)",
|
||||||
border: '1px solid var(--landing-button-border)',
|
border: "1px solid var(--landing-button-border)",
|
||||||
borderRadius: '2rem',
|
borderRadius: "2rem",
|
||||||
height: '38px',
|
height: "38px",
|
||||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
paddingLeft: isUploadHover ? 0 : "1rem",
|
||||||
paddingRight: isUploadHover ? 0 : '1rem',
|
paddingRight: isUploadHover ? 0 : "1rem",
|
||||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
width: isUploadHover ? "58px" : "calc(100% - 58px - 0.6rem)",
|
||||||
minWidth: isUploadHover ? '58px' : undefined,
|
minWidth: isUploadHover ? "58px" : undefined,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
transition: 'width .5s ease, padding .5s ease'
|
transition: "width .5s ease, padding .5s ease",
|
||||||
}}
|
}}
|
||||||
onClick={handleOpenFilesModal}
|
onClick={handleOpenFilesModal}
|
||||||
onMouseEnter={() => setIsUploadHover(false)}
|
onMouseEnter={() => setIsUploadHover(false)}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||||
{!isUploadHover && (
|
{!isUploadHover && <span>{t("landing.addFiles", "Add Files")}</span>}
|
||||||
<span>
|
|
||||||
{t('landing.addFiles', 'Add Files')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Upload"
|
aria-label="Upload"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--landing-button-bg)',
|
backgroundColor: "var(--landing-button-bg)",
|
||||||
color: 'var(--landing-button-color)',
|
color: "var(--landing-button-color)",
|
||||||
border: '1px solid var(--landing-button-border)',
|
border: "1px solid var(--landing-button-border)",
|
||||||
borderRadius: '1rem',
|
borderRadius: "1rem",
|
||||||
height: '38px',
|
height: "38px",
|
||||||
width: isUploadHover ? 'calc(100% - 58px - 0.6rem)' : '58px',
|
width: isUploadHover ? "calc(100% - 58px - 0.6rem)" : "58px",
|
||||||
minWidth: '58px',
|
minWidth: "58px",
|
||||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
paddingLeft: isUploadHover ? "1rem" : 0,
|
||||||
paddingRight: isUploadHover ? '1rem' : 0,
|
paddingRight: isUploadHover ? "1rem" : 0,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
transition: 'width .5s ease, padding .5s ease'
|
transition: "width .5s ease, padding .5s ease",
|
||||||
}}
|
}}
|
||||||
onClick={handleNativeUploadClick}
|
onClick={handleNativeUploadClick}
|
||||||
onMouseEnter={() => setIsUploadHover(true)}
|
onMouseEnter={() => setIsUploadHover(true)}
|
||||||
>
|
>
|
||||||
<LocalIcon icon={icons.uploadIconName} width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
<LocalIcon
|
||||||
{isUploadHover && (
|
icon={icons.uploadIconName}
|
||||||
<span style={{ marginLeft: '.5rem' }}>
|
width="1.25rem"
|
||||||
{terminology.uploadFromComputer}
|
height="1.25rem"
|
||||||
</span>
|
style={{ color: "var(--accent-interactive)" }}
|
||||||
)}
|
/>
|
||||||
|
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Instruction Text */}
|
{/* Instruction Text */}
|
||||||
<span
|
<span
|
||||||
className="text-[var(--accent-interactive)]"
|
className="text-[var(--accent-interactive)]"
|
||||||
style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }}
|
style={{ fontSize: ".8rem", textAlign: "center", marginTop: "0.5rem" }}
|
||||||
>
|
>
|
||||||
{terminology.dropFilesHere}
|
{terminology.dropFilesHere}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
background: var(--file-card-bg);
|
background: var(--file-card-bg);
|
||||||
border-radius: 0.0625rem;
|
border-radius: 0.0625rem;
|
||||||
cursor: pointer;
|
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-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@@ -45,8 +48,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.headerResting {
|
.headerResting {
|
||||||
background: #3B4B6E; /* dark blue for unselected in light mode */
|
background: #3b4b6e; /* dark blue for unselected in light mode */
|
||||||
color: #FFFFFF;
|
color: #ffffff;
|
||||||
border-bottom: 1px solid var(--border-default);
|
border-bottom: 1px solid var(--border-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +69,7 @@
|
|||||||
/* Unsupported (but not errored) header appearance */
|
/* Unsupported (but not errored) header appearance */
|
||||||
.headerUnsupported {
|
.headerUnsupported {
|
||||||
background: var(--unsupported-bar-bg); /* neutral gray */
|
background: var(--unsupported-bar-bg); /* neutral gray */
|
||||||
color: #FFFFFF;
|
color: #ffffff;
|
||||||
border-bottom: 1px solid var(--unsupported-bar-border);
|
border-bottom: 1px solid var(--unsupported-bar-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +106,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.headerIconButton {
|
.headerIconButton {
|
||||||
color: #FFFFFF !important;
|
color: #ffffff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Menu dropdown */
|
/* Menu dropdown */
|
||||||
@@ -226,14 +229,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pinned {
|
.pinned {
|
||||||
color: #FFC107 !important;
|
color: #ffc107 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Unsupported file indicator */
|
/* Unsupported file indicator */
|
||||||
.unsupportedPill {
|
.unsupportedPill {
|
||||||
margin-left: 1.75rem;
|
margin-left: 1.75rem;
|
||||||
background: #6B7280;
|
background: #6b7280;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -264,7 +266,8 @@
|
|||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -288,15 +291,15 @@
|
|||||||
DARK MODE OVERRIDES
|
DARK MODE OVERRIDES
|
||||||
========================= */
|
========================= */
|
||||||
:global([data-mantine-color-scheme="dark"]) .card {
|
: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"] {
|
: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 {
|
: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 */
|
color: var(--tool-header-text); /* #D0D6DC */
|
||||||
border-bottom-color: var(--tool-header-border); /* #3A4047 */
|
border-bottom-color: var(--tool-header-border); /* #3A4047 */
|
||||||
}
|
}
|
||||||
@@ -308,16 +311,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global([data-mantine-color-scheme="dark"]) .title {
|
:global([data-mantine-color-scheme="dark"]) .title {
|
||||||
color: #D0D6DC; /* title text */
|
color: #d0d6dc; /* title text */
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([data-mantine-color-scheme="dark"]) .meta {
|
:global([data-mantine-color-scheme="dark"]) .meta {
|
||||||
color: #6B7280; /* subtitle text */
|
color: #6b7280; /* subtitle text */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light mode selected header stroke override */
|
/* Light mode selected header stroke override */
|
||||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||||
outline-color: #3B4B6E;
|
outline-color: #3b4b6e;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
||||||
import {
|
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||||
Text, Center, Box, LoadingOverlay, Stack
|
import { Dropzone } from "@mantine/dropzone";
|
||||||
} from '@mantine/core';
|
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from "@app/contexts/FileContext";
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||||
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext';
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
import { zipFileService } from "@app/services/zipFileService";
|
||||||
import { useViewer } from '@app/contexts/ViewerContext';
|
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||||
import { zipFileService } from '@app/services/zipFileService';
|
import FileEditorThumbnail from "@app/components/fileEditor/FileEditorThumbnail";
|
||||||
import { detectFileExtension } from '@app/utils/fileUtils';
|
import AddFileCard from "@app/components/fileEditor/AddFileCard";
|
||||||
import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail';
|
import FilePickerModal from "@app/components/shared/FilePickerModal";
|
||||||
import AddFileCard from '@app/components/fileEditor/AddFileCard';
|
import { FileId, StirlingFile } from "@app/types/fileContext";
|
||||||
import FilePickerModal from '@app/components/shared/FilePickerModal';
|
import { alert } from "@app/components/toast";
|
||||||
import { FileId, StirlingFile } from '@app/types/fileContext';
|
import { downloadFile } from "@app/services/downloadService";
|
||||||
import { alert } from '@app/components/toast';
|
import { useFileEditorRightRailButtons } from "@app/components/fileEditor/fileEditorRightRailButtons";
|
||||||
import { downloadFile } from '@app/services/downloadService';
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
|
|
||||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
|
||||||
|
|
||||||
|
|
||||||
interface FileEditorProps {
|
interface FileEditorProps {
|
||||||
onOpenPageEditor?: () => void;
|
onOpenPageEditor?: () => void;
|
||||||
@@ -25,16 +22,15 @@ interface FileEditorProps {
|
|||||||
supportedExtensions?: string[];
|
supportedExtensions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileEditor = ({
|
const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEditorProps) => {
|
||||||
toolMode = false,
|
|
||||||
supportedExtensions = ["pdf"]
|
|
||||||
}: FileEditorProps) => {
|
|
||||||
|
|
||||||
// Utility function to check if a file extension is supported
|
// Utility function to check if a file extension is supported
|
||||||
const isFileSupported = useCallback((fileName: string): boolean => {
|
const isFileSupported = useCallback(
|
||||||
const extension = detectFileExtension(fileName);
|
(fileName: string): boolean => {
|
||||||
return extension ? supportedExtensions.includes(extension) : false;
|
const extension = detectFileExtension(fileName);
|
||||||
}, [supportedExtensions]);
|
return extension ? supportedExtensions.includes(extension) : false;
|
||||||
|
},
|
||||||
|
[supportedExtensions],
|
||||||
|
);
|
||||||
|
|
||||||
// Use optimized FileContext hooks
|
// Use optimized FileContext hooks
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
@@ -62,11 +58,11 @@ const FileEditor = ({
|
|||||||
const [_error, _setError] = useState<string | null>(null);
|
const [_error, _setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Toast helpers
|
// 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 });
|
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
|
||||||
}, []);
|
}, []);
|
||||||
const showError = useCallback((message: string) => {
|
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);
|
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||||
|
|
||||||
@@ -76,7 +72,7 @@ const FileEditor = ({
|
|||||||
// Compute effective max allowed files based on the active tool and mode
|
// Compute effective max allowed files based on the active tool and mode
|
||||||
const maxAllowed = useMemo<number>(() => {
|
const maxAllowed = useMemo<number>(() => {
|
||||||
const rawMax = selectedTool?.maxFiles;
|
const rawMax = selectedTool?.maxFiles;
|
||||||
return (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax;
|
return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
|
||||||
}, [selectedTool?.maxFiles, toolMode]);
|
}, [selectedTool?.maxFiles, toolMode]);
|
||||||
|
|
||||||
// Enable selection mode automatically in tool mode
|
// Enable selection mode automatically in tool mode
|
||||||
@@ -104,8 +100,8 @@ const FileEditor = ({
|
|||||||
try {
|
try {
|
||||||
clearAllFileErrors();
|
clearAllFileErrors();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === "development") {
|
||||||
console.warn('Failed to clear file errors on select all:', error);
|
console.warn("Failed to clear file errors on select all:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]);
|
}, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]);
|
||||||
@@ -115,8 +111,8 @@ const FileEditor = ({
|
|||||||
try {
|
try {
|
||||||
clearAllFileErrors();
|
clearAllFileErrors();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === "development") {
|
||||||
console.warn('Failed to clear file errors on deselect:', error);
|
console.warn("Failed to clear file errors on deselect:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [setSelectedFiles, clearAllFileErrors]);
|
}, [setSelectedFiles, clearAllFileErrors]);
|
||||||
@@ -137,69 +133,75 @@ const FileEditor = ({
|
|||||||
|
|
||||||
// Process uploaded files using context
|
// Process uploaded files using context
|
||||||
// ZIP extraction is now handled automatically in FileContext based on user preferences
|
// ZIP extraction is now handled automatically in FileContext based on user preferences
|
||||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
const handleFileUpload = useCallback(
|
||||||
_setError(null);
|
async (uploadedFiles: File[]) => {
|
||||||
|
_setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (uploadedFiles.length > 0) {
|
if (uploadedFiles.length > 0) {
|
||||||
// FileContext will automatically handle ZIP extraction based on user preferences
|
// FileContext will automatically handle ZIP extraction based on user preferences
|
||||||
// - Respects autoUnzip setting
|
// - Respects autoUnzip setting
|
||||||
// - Respects autoUnzipFileLimit
|
// - Respects autoUnzipFileLimit
|
||||||
// - HTML ZIPs stay intact
|
// - HTML ZIPs stay intact
|
||||||
// - Non-ZIP files pass through unchanged
|
// - Non-ZIP files pass through unchanged
|
||||||
await addFiles(uploadedFiles, { selectFiles: true });
|
await addFiles(uploadedFiles, { selectFiles: true });
|
||||||
// After auto-selection, enforce maxAllowed if needed
|
// After auto-selection, enforce maxAllowed if needed
|
||||||
if (Number.isFinite(maxAllowed)) {
|
if (Number.isFinite(maxAllowed)) {
|
||||||
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map(r => r.id);
|
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map((r) => r.id);
|
||||||
if (nowSelectedIds.length > maxAllowed) {
|
if (nowSelectedIds.length > maxAllowed) {
|
||||||
setSelectedFiles(nowSelectedIds.slice(-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) => {
|
// Update context (this automatically updates tool selection since they use the same action)
|
||||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
setSelectedFiles(newSelection);
|
||||||
|
},
|
||||||
const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
|
[setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles],
|
||||||
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]);
|
|
||||||
|
|
||||||
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
|
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -208,154 +210,174 @@ const FileEditor = ({
|
|||||||
}
|
}
|
||||||
}, [maxAllowed, selectedFileIds, setSelectedFiles]);
|
}, [maxAllowed, selectedFileIds, setSelectedFiles]);
|
||||||
|
|
||||||
|
|
||||||
// File reordering handler for drag and drop
|
// File reordering handler for drag and drop
|
||||||
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
|
const handleReorderFiles = useCallback(
|
||||||
const currentIds = activeStirlingFileStubs.map(r => r.id);
|
(sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
|
||||||
|
const currentIds = activeStirlingFileStubs.map((r) => r.id);
|
||||||
|
|
||||||
// Find indices
|
// Find indices
|
||||||
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
|
const sourceIndex = currentIds.findIndex((id) => id === sourceFileId);
|
||||||
const targetIndex = currentIds.findIndex(id => id === targetFileId);
|
const targetIndex = currentIds.findIndex((id) => id === targetFileId);
|
||||||
|
|
||||||
if (sourceIndex === -1 || targetIndex === -1) {
|
if (sourceIndex === -1 || targetIndex === -1) {
|
||||||
console.warn('Could not find source or target file for reordering');
|
console.warn("Could not find source or target file for reordering");
|
||||||
return;
|
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)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Target was moved, insert at end
|
|
||||||
insertIndex = newOrder.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert files at the calculated position
|
// Handle multi-file selection reordering
|
||||||
newOrder.splice(insertIndex, 0, ...filesToMove);
|
const filesToMove =
|
||||||
|
selectedFileIds.length > 1 ? selectedFileIds.filter((id) => currentIds.includes(id)) : [sourceFileId];
|
||||||
|
|
||||||
// Update file order
|
// Create new order
|
||||||
reorderFiles(newOrder);
|
const newOrder = [...currentIds];
|
||||||
|
|
||||||
// Update status
|
// Remove files to move from their current positions (in reverse order to maintain indices)
|
||||||
const moveCount = filesToMove.length;
|
const sourceIndices = filesToMove.map((id) => newOrder.findIndex((nId) => nId === id)).sort((a, b) => b - a); // Sort descending
|
||||||
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
|
||||||
}, [activeStirlingFileStubs, reorderFiles, _setStatus]);
|
|
||||||
|
|
||||||
|
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
|
// File operations using context
|
||||||
const handleCloseFile = useCallback((fileId: FileId) => {
|
const handleCloseFile = useCallback(
|
||||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
(fileId: FileId) => {
|
||||||
const file = record ? selectors.getFile(record.id) : null;
|
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
|
||||||
if (record && file) {
|
const file = record ? selectors.getFile(record.id) : null;
|
||||||
// Remove file from context but keep in storage (close, don't delete)
|
if (record && file) {
|
||||||
const contextFileId = record.id;
|
// Remove file from context but keep in storage (close, don't delete)
|
||||||
removeFiles([contextFileId], false);
|
const contextFileId = record.id;
|
||||||
|
removeFiles([contextFileId], false);
|
||||||
|
|
||||||
// Remove from context selections
|
// Remove from context selections
|
||||||
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
|
const currentSelected = selectedFileIds.filter((id) => id !== contextFileId);
|
||||||
setSelectedFiles(currentSelected);
|
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 });
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}, [activeStirlingFileStubs, selectors, fileActions]);
|
[activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds],
|
||||||
|
);
|
||||||
|
|
||||||
const handleUnzipFile = useCallback(async (fileId: FileId) => {
|
const handleDownloadFile = useCallback(
|
||||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
async (fileId: FileId) => {
|
||||||
const file = record ? selectors.getFile(record.id) : null;
|
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
|
||||||
if (record && file) {
|
const file = record ? selectors.getFile(record.id) : null;
|
||||||
try {
|
console.log("[FileEditor] handleDownloadFile called:", {
|
||||||
// Extract and store files using shared service method
|
fileId,
|
||||||
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
|
hasRecord: !!record,
|
||||||
|
hasFile: !!file,
|
||||||
if (result.success && result.extractedStubs.length > 0) {
|
localFilePath: record?.localFilePath,
|
||||||
// Add extracted file stubs to FileContext
|
isDirty: record?.isDirty,
|
||||||
await fileActions.addStirlingFileStubs(result.extractedStubs);
|
});
|
||||||
|
if (record && file) {
|
||||||
// Remove the original ZIP file
|
const result = await downloadFile({
|
||||||
removeFiles([fileId], false);
|
data: file,
|
||||||
|
filename: file.name,
|
||||||
alert({
|
localPath: record.localFilePath,
|
||||||
alertType: 'success',
|
});
|
||||||
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
|
console.log("[FileEditor] Download complete, checking dirty state:", {
|
||||||
expandable: false,
|
localFilePath: record.localFilePath,
|
||||||
durationMs: 3500
|
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 {
|
} 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({
|
alert({
|
||||||
alertType: 'error',
|
alertType: "error",
|
||||||
title: `Failed to extract files from ${file.name}`,
|
title: `Error unzipping ${file.name}`,
|
||||||
body: result.errors.join('\n'),
|
expandable: false,
|
||||||
expandable: true,
|
durationMs: 3500,
|
||||||
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 handleViewFile = useCallback(
|
||||||
const index = activeStirlingFileStubs.findIndex(r => r.id === fileId);
|
(fileId: FileId) => {
|
||||||
if (index !== -1) {
|
const index = activeStirlingFileStubs.findIndex((r) => r.id === fileId);
|
||||||
setActiveFileId(fileId as string);
|
if (index !== -1) {
|
||||||
setActiveFileIndex(index);
|
setActiveFileId(fileId as string);
|
||||||
navActions.setWorkbench('viewer');
|
setActiveFileIndex(index);
|
||||||
}
|
navActions.setWorkbench("viewer");
|
||||||
}, [activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench]);
|
}
|
||||||
|
},
|
||||||
|
[activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench],
|
||||||
|
);
|
||||||
|
|
||||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||||
if (selectedFiles.length === 0) return;
|
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
|
// The files are already in FileContext, just need to add them to active files
|
||||||
showStatus(`Loaded ${selectedFiles.length} files from storage`);
|
showStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading files from storage:', err);
|
console.error("Error loading files from storage:", err);
|
||||||
showError('Failed to load some files from storage');
|
showError("Failed to load some files from storage");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={handleFileUpload}
|
onDrop={handleFileUpload}
|
||||||
multiple={true}
|
multiple={true}
|
||||||
maxSize={2 * 1024 * 1024 * 1024}
|
maxSize={2 * 1024 * 1024 * 1024}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: "none",
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
backgroundColor: 'transparent'
|
backgroundColor: "transparent",
|
||||||
}}
|
}}
|
||||||
activateOnClick={false}
|
activateOnClick={false}
|
||||||
activateOnDrag={true}
|
activateOnDrag={true}
|
||||||
>
|
>
|
||||||
<Box pos="relative" style={{ overflow: 'auto' }}>
|
<Box pos="relative" style={{ overflow: "auto" }}>
|
||||||
<LoadingOverlay visible={state.ui.isProcessing} />
|
<LoadingOverlay visible={state.ui.isProcessing} />
|
||||||
|
|
||||||
<Box p="md">
|
<Box p="md">
|
||||||
|
{activeStirlingFileStubs.length === 0 ? (
|
||||||
|
<Center h="60vh">
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<Text size="lg" c="dimmed">
|
||||||
|
📁
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed">No files loaded</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Upload PDF files, ZIP archives, or load from storage to get started
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
|
||||||
|
rowGap: "1.5rem",
|
||||||
|
padding: "1rem",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Add File Card - only show when files exist */}
|
||||||
|
{activeStirlingFileStubs.length > 0 && <AddFileCard key="add-file-card" onFileSelect={handleFileUpload} />}
|
||||||
|
|
||||||
|
{activeStirlingFileStubs.map((record, index) => {
|
||||||
|
return (
|
||||||
|
<FileEditorThumbnail
|
||||||
|
key={record.id}
|
||||||
|
file={record}
|
||||||
|
index={index}
|
||||||
|
totalFiles={activeStirlingFileStubs.length}
|
||||||
|
selectedFiles={localSelectedIds}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
onToggleFile={toggleFile}
|
||||||
|
onCloseFile={handleCloseFile}
|
||||||
|
onViewFile={handleViewFile}
|
||||||
|
_onSetStatus={showStatus}
|
||||||
|
onReorderFiles={handleReorderFiles}
|
||||||
|
onDownloadFile={handleDownloadFile}
|
||||||
|
onUnzipFile={handleUnzipFile}
|
||||||
|
toolMode={toolMode}
|
||||||
|
isSupported={isFileSupported(record.name)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{activeStirlingFileStubs.length === 0 ? (
|
{/* File Picker Modal */}
|
||||||
<Center h="60vh">
|
<FilePickerModal
|
||||||
<Stack align="center" gap="md">
|
opened={showFilePickerModal}
|
||||||
<Text size="lg" c="dimmed">📁</Text>
|
onClose={() => setShowFilePickerModal(false)}
|
||||||
<Text c="dimmed">No files loaded</Text>
|
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
|
||||||
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
onSelectFiles={handleLoadFromStorage}
|
||||||
</Stack>
|
/>
|
||||||
</Center>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
|
||||||
rowGap: '1.5rem',
|
|
||||||
padding: '1rem',
|
|
||||||
pointerEvents: 'auto'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Add File Card - only show when files exist */}
|
|
||||||
{activeStirlingFileStubs.length > 0 && (
|
|
||||||
<AddFileCard
|
|
||||||
key="add-file-card"
|
|
||||||
onFileSelect={handleFileUpload}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeStirlingFileStubs.map((record, index) => {
|
|
||||||
return (
|
|
||||||
<FileEditorThumbnail
|
|
||||||
key={record.id}
|
|
||||||
file={record}
|
|
||||||
index={index}
|
|
||||||
totalFiles={activeStirlingFileStubs.length}
|
|
||||||
selectedFiles={localSelectedIds}
|
|
||||||
selectionMode={selectionMode}
|
|
||||||
onToggleFile={toggleFile}
|
|
||||||
onCloseFile={handleCloseFile}
|
|
||||||
onViewFile={handleViewFile}
|
|
||||||
_onSetStatus={showStatus}
|
|
||||||
onReorderFiles={handleReorderFiles}
|
|
||||||
onDownloadFile={handleDownloadFile}
|
|
||||||
onUnzipFile={handleUnzipFile}
|
|
||||||
toolMode={toolMode}
|
|
||||||
isSupported={isFileSupported(record.name)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* File Picker Modal */}
|
|
||||||
<FilePickerModal
|
|
||||||
opened={showFilePickerModal}
|
|
||||||
onClose={() => setShowFilePickerModal(false)}
|
|
||||||
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
|
|
||||||
onSelectFiles={handleLoadFromStorage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { StirlingFileStub } from '@app/types/fileContext';
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||||
|
|
||||||
interface FileEditorFileNameProps {
|
interface FileEditorFileNameProps {
|
||||||
file: StirlingFileStub;
|
file: StirlingFileStub;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
|
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => <PrivateContent>{file.name}</PrivateContent>;
|
||||||
<PrivateContent>{file.name}</PrivateContent>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default FileEditorFileName;
|
export default FileEditorFileName;
|
||||||
|
|||||||
@@ -1,38 +1,36 @@
|
|||||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
import React, { useState, useCallback, useRef, useMemo } from "react";
|
||||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from '@mantine/core';
|
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from "@mantine/core";
|
||||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from "@app/components/toast";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
import UnarchiveIcon from "@mui/icons-material/Unarchive";
|
||||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||||
import LinkIcon from '@mui/icons-material/Link';
|
import LinkIcon from "@mui/icons-material/Link";
|
||||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
import PushPinIcon from "@mui/icons-material/PushPin";
|
||||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
||||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||||
import { StirlingFileStub } from '@app/types/fileContext';
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import { zipFileService } from '@app/services/zipFileService';
|
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 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 {
|
interface FileEditorThumbnailProps {
|
||||||
file: StirlingFileStub;
|
file: StirlingFileStub;
|
||||||
@@ -69,14 +67,7 @@ const FileEditorThumbnail = ({
|
|||||||
const terminology = useFileActionTerminology();
|
const terminology = useFileActionTerminology();
|
||||||
const icons = useFileActionIcons();
|
const icons = useFileActionIcons();
|
||||||
const DownloadOutlinedIcon = icons.download;
|
const DownloadOutlinedIcon = icons.download;
|
||||||
const {
|
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions, openEncryptedUnlockPrompt } = useFileContext();
|
||||||
pinFile,
|
|
||||||
unpinFile,
|
|
||||||
isFilePinned,
|
|
||||||
activeFiles,
|
|
||||||
actions: fileActions,
|
|
||||||
openEncryptedUnlockPrompt,
|
|
||||||
} = useFileContext();
|
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||||
|
|
||||||
@@ -93,7 +84,7 @@ const FileEditorThumbnail = ({
|
|||||||
|
|
||||||
// Resolve the actual File object for pin/unpin operations
|
// Resolve the actual File object for pin/unpin operations
|
||||||
const actualFile = useMemo(() => {
|
const actualFile = useMemo(() => {
|
||||||
return activeFiles.find(f => f.fileId === file.id);
|
return activeFiles.find((f) => f.fileId === file.id);
|
||||||
}, [activeFiles, file.id]);
|
}, [activeFiles, file.id]);
|
||||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||||
|
|
||||||
@@ -114,17 +105,17 @@ const FileEditorThumbnail = ({
|
|||||||
}, [file.size]);
|
}, [file.size]);
|
||||||
|
|
||||||
const extUpper = useMemo(() => {
|
const extUpper = useMemo(() => {
|
||||||
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
|
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
|
||||||
return (m?.[1] || '').toUpperCase();
|
return (m?.[1] || "").toUpperCase();
|
||||||
}, [file.name]);
|
}, [file.name]);
|
||||||
|
|
||||||
const extLower = useMemo(() => {
|
const extLower = useMemo(() => {
|
||||||
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
|
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
|
||||||
return (m?.[1] || '').toLowerCase();
|
return (m?.[1] || "").toLowerCase();
|
||||||
}, [file.name]);
|
}, [file.name]);
|
||||||
|
|
||||||
const isCBZ = extLower === 'cbz';
|
const isCBZ = extLower === "cbz";
|
||||||
const isCBR = extLower === 'cbr';
|
const isCBR = extLower === "cbr";
|
||||||
const uploadEnabled = config?.storageEnabled === true;
|
const uploadEnabled = config?.storageEnabled === true;
|
||||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||||
@@ -137,71 +128,68 @@ const FileEditorThumbnail = ({
|
|||||||
const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate);
|
const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate);
|
||||||
const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf;
|
const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf;
|
||||||
|
|
||||||
const pageLabel = useMemo(
|
const pageLabel = useMemo(() => (pageCount > 0 ? `${pageCount} ${pageCount === 1 ? "Page" : "Pages"}` : ""), [pageCount]);
|
||||||
() =>
|
|
||||||
pageCount > 0
|
|
||||||
? `${pageCount} ${pageCount === 1 ? 'Page' : 'Pages'}`
|
|
||||||
: '',
|
|
||||||
[pageCount]
|
|
||||||
);
|
|
||||||
|
|
||||||
const dateLabel = useMemo(() => {
|
const dateLabel = useMemo(() => {
|
||||||
const d = new Date(file.lastModified);
|
const d = new Date(file.lastModified);
|
||||||
if (Number.isNaN(d.getTime())) return '';
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: '2-digit',
|
day: "2-digit",
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
}).format(d);
|
}).format(d);
|
||||||
}, [file.lastModified]);
|
}, [file.lastModified]);
|
||||||
|
|
||||||
// ---- Drag & drop wiring ----
|
// ---- Drag & drop wiring ----
|
||||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
const fileElementRef = useCallback(
|
||||||
if (!element) return;
|
(element: HTMLDivElement | null) => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
dragElementRef.current = element;
|
dragElementRef.current = element;
|
||||||
|
|
||||||
const dragCleanup = draggable({
|
const dragCleanup = draggable({
|
||||||
element,
|
element,
|
||||||
getInitialData: () => ({
|
getInitialData: () => ({
|
||||||
type: 'file',
|
type: "file",
|
||||||
fileId: file.id,
|
fileId: file.id,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
selectedFiles: [file.id] // Always drag only this file, ignore selection state
|
selectedFiles: [file.id], // Always drag only this file, ignore selection state
|
||||||
}),
|
}),
|
||||||
onDragStart: () => {
|
onDragStart: () => {
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
},
|
},
|
||||||
onDrop: () => {
|
onDrop: () => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const dropCleanup = dropTargetForElements({
|
const dropCleanup = dropTargetForElements({
|
||||||
element,
|
element,
|
||||||
getData: () => ({
|
getData: () => ({
|
||||||
type: 'file',
|
type: "file",
|
||||||
fileId: file.id
|
fileId: file.id,
|
||||||
}),
|
}),
|
||||||
canDrop: ({ source }) => {
|
canDrop: ({ source }) => {
|
||||||
const sourceData = source.data;
|
const sourceData = source.data;
|
||||||
return sourceData.type === 'file' && sourceData.fileId !== file.id;
|
return sourceData.type === "file" && sourceData.fileId !== file.id;
|
||||||
},
|
},
|
||||||
onDrop: ({ source }) => {
|
onDrop: ({ source }) => {
|
||||||
const sourceData = source.data;
|
const sourceData = source.data;
|
||||||
if (sourceData.type === 'file' && onReorderFiles) {
|
if (sourceData.type === "file" && onReorderFiles) {
|
||||||
const sourceFileId = sourceData.fileId as FileId;
|
const sourceFileId = sourceData.fileId as FileId;
|
||||||
const selectedFileIds = sourceData.selectedFiles as FileId[];
|
const selectedFileIds = sourceData.selectedFiles as FileId[];
|
||||||
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
dragCleanup();
|
dragCleanup();
|
||||||
dropCleanup();
|
dropCleanup();
|
||||||
};
|
};
|
||||||
}, [file.id, file.name, selectedFiles, onReorderFiles]);
|
},
|
||||||
|
[file.id, file.name, selectedFiles, onReorderFiles],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle close with confirmation
|
// Handle close with confirmation
|
||||||
const handleCloseWithConfirmation = useCallback(() => {
|
const handleCloseWithConfirmation = useCallback(() => {
|
||||||
@@ -210,7 +198,7 @@ const FileEditorThumbnail = ({
|
|||||||
|
|
||||||
const handleConfirmClose = useCallback(() => {
|
const handleConfirmClose = useCallback(() => {
|
||||||
onCloseFile(file.id);
|
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);
|
setShowCloseModal(false);
|
||||||
}, [file.id, file.name, onCloseFile]);
|
}, [file.id, file.name, onCloseFile]);
|
||||||
|
|
||||||
@@ -221,12 +209,12 @@ const FileEditorThumbnail = ({
|
|||||||
const result = await downloadFile({
|
const result = await downloadFile({
|
||||||
data: fileToSave,
|
data: fileToSave,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
localPath: file.localFilePath
|
localPath: file.localFilePath,
|
||||||
});
|
});
|
||||||
if (!result.cancelled && result.savedPath) {
|
if (!result.cancelled && result.savedPath) {
|
||||||
fileActions.updateStirlingFileStub(file.id, {
|
fileActions.updateStirlingFileStub(file.id, {
|
||||||
localFilePath: file.localFilePath ?? result.savedPath,
|
localFilePath: file.localFilePath ?? result.savedPath,
|
||||||
isDirty: false
|
isDirty: false,
|
||||||
});
|
});
|
||||||
} else if (result.cancelled) {
|
} else if (result.cancelled) {
|
||||||
setShowCloseModal(false);
|
setShowCloseModal(false);
|
||||||
@@ -234,14 +222,14 @@ const FileEditorThumbnail = ({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to save ${file.name}:`, 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);
|
setShowCloseModal(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Then close
|
// Then close
|
||||||
onCloseFile(file.id);
|
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);
|
setShowCloseModal(false);
|
||||||
}, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]);
|
}, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]);
|
||||||
|
|
||||||
@@ -250,95 +238,110 @@ const FileEditorThumbnail = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Build hover menu actions
|
// Build hover menu actions
|
||||||
const hoverActions = useMemo<HoverAction[]>(() => [
|
const hoverActions = useMemo<HoverAction[]>(
|
||||||
{
|
() => [
|
||||||
id: 'view',
|
{
|
||||||
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
|
id: "view",
|
||||||
label: t('openInViewer', 'Open in Viewer'),
|
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
|
||||||
onClick: (e) => {
|
label: t("openInViewer", "Open in Viewer"),
|
||||||
e.stopPropagation();
|
onClick: (e) => {
|
||||||
onViewFile(file.id);
|
e.stopPropagation();
|
||||||
|
onViewFile(file.id);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
id: "download",
|
||||||
id: 'download',
|
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
||||||
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
label: terminology.download,
|
||||||
label: terminology.download,
|
onClick: (e) => {
|
||||||
onClick: (e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
onDownloadFile(file.id);
|
||||||
onDownloadFile(file.id);
|
},
|
||||||
},
|
},
|
||||||
},
|
...(canUpload || canShare
|
||||||
...(canUpload || canShare
|
? [
|
||||||
? [
|
...(canUpload
|
||||||
...(canUpload ? [{
|
? [
|
||||||
id: 'upload',
|
{
|
||||||
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
id: "upload",
|
||||||
label: isUploaded
|
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
||||||
? t('fileManager.updateOnServer', 'Update on Server')
|
label: isUploaded
|
||||||
: t('fileManager.uploadToServer', 'Upload to Server'),
|
? t("fileManager.updateOnServer", "Update on Server")
|
||||||
onClick: (e: React.MouseEvent) => {
|
: t("fileManager.uploadToServer", "Upload to Server"),
|
||||||
e.stopPropagation();
|
onClick: (e: React.MouseEvent) => {
|
||||||
setShowUploadModal(true);
|
e.stopPropagation();
|
||||||
},
|
setShowUploadModal(true);
|
||||||
}] : []),
|
},
|
||||||
...(canShare ? [{
|
},
|
||||||
id: 'share',
|
]
|
||||||
icon: <LinkIcon style={{ fontSize: 20 }} />,
|
: []),
|
||||||
label: t('fileManager.share', 'Share'),
|
...(canShare
|
||||||
onClick: (e: React.MouseEvent) => {
|
? [
|
||||||
e.stopPropagation();
|
{
|
||||||
setShowShareModal(true);
|
id: "share",
|
||||||
},
|
icon: <LinkIcon style={{ fontSize: 20 }} />,
|
||||||
}] : []),
|
label: t("fileManager.share", "Share"),
|
||||||
]
|
onClick: (e: React.MouseEvent) => {
|
||||||
: []),
|
e.stopPropagation();
|
||||||
{
|
setShowShareModal(true);
|
||||||
id: 'unzip',
|
},
|
||||||
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
|
},
|
||||||
label: t('fileManager.unzip', 'Unzip'),
|
]
|
||||||
onClick: (e) => {
|
: []),
|
||||||
e.stopPropagation();
|
]
|
||||||
if (onUnzipFile) {
|
: []),
|
||||||
onUnzipFile(file.id);
|
{
|
||||||
alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
|
id: "unzip",
|
||||||
}
|
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
|
||||||
|
label: t("fileManager.unzip", "Unzip"),
|
||||||
|
onClick: (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onUnzipFile) {
|
||||||
|
onUnzipFile(file.id);
|
||||||
|
alert({ alertType: "success", title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
|
||||||
},
|
},
|
||||||
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
|
{
|
||||||
},
|
id: "close",
|
||||||
{
|
icon: <CloseIcon style={{ fontSize: 20 }} />,
|
||||||
id: 'close',
|
label: t("close", "Close"),
|
||||||
icon: <CloseIcon style={{ fontSize: 20 }} />,
|
onClick: (e) => {
|
||||||
label: t('close', 'Close'),
|
e.stopPropagation();
|
||||||
onClick: (e) => {
|
handleCloseWithConfirmation();
|
||||||
e.stopPropagation();
|
},
|
||||||
handleCloseWithConfirmation();
|
color: "red",
|
||||||
},
|
},
|
||||||
color: 'red',
|
],
|
||||||
}
|
[
|
||||||
], [
|
t,
|
||||||
t,
|
file.id,
|
||||||
file.id,
|
file.name,
|
||||||
file.name,
|
isZipFile,
|
||||||
isZipFile,
|
isCBZ,
|
||||||
isCBZ,
|
isCBR,
|
||||||
isCBR,
|
terminology,
|
||||||
terminology,
|
onViewFile,
|
||||||
onViewFile,
|
onDownloadFile,
|
||||||
onDownloadFile,
|
onUnzipFile,
|
||||||
onUnzipFile,
|
handleCloseWithConfirmation,
|
||||||
handleCloseWithConfirmation,
|
canUpload,
|
||||||
canUpload,
|
canShare,
|
||||||
canShare,
|
isUploaded,
|
||||||
isUploaded
|
],
|
||||||
]);
|
);
|
||||||
|
|
||||||
// ---- Card interactions ----
|
// ---- Card interactions ----
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
if (!isSupported) return;
|
if (!isSupported) return;
|
||||||
// Clear error state if file has an error (click to clear error)
|
// Clear error state if file has an error (click to clear error)
|
||||||
if (hasError) {
|
if (hasError) {
|
||||||
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
|
try {
|
||||||
|
fileActions.clearFileError(file.id);
|
||||||
|
} catch (_e) {
|
||||||
|
void _e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (isSharedFile && !sharedEditNoticeShownRef.current) {
|
if (isSharedFile && !sharedEditNoticeShownRef.current) {
|
||||||
sharedEditNoticeShownRef.current = true;
|
sharedEditNoticeShownRef.current = true;
|
||||||
@@ -359,7 +362,6 @@ const FileEditorThumbnail = ({
|
|||||||
return isSelected ? styles.headerSelected : styles.headerResting;
|
return isSelected ? styles.headerSelected : styles.headerResting;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={fileElementRef}
|
ref={fileElementRef}
|
||||||
@@ -369,7 +371,7 @@ const FileEditorThumbnail = ({
|
|||||||
data-selected={isSelected}
|
data-selected={isSelected}
|
||||||
data-supported={isSupported}
|
data-supported={isSupported}
|
||||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||||
style={{opacity: isDragging ? 0.9 : 1}}
|
style={{ opacity: isDragging ? 0.9 : 1 }}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
@@ -379,15 +381,12 @@ const FileEditorThumbnail = ({
|
|||||||
onDoubleClick={handleCardDoubleClick}
|
onDoubleClick={handleCardDoubleClick}
|
||||||
>
|
>
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div
|
<div className={`${styles.header} ${getHeaderClassName()}`} data-has-error={hasError}>
|
||||||
className={`${styles.header} ${getHeaderClassName()}`}
|
|
||||||
data-has-error={hasError}
|
|
||||||
>
|
|
||||||
{/* Logo/checkbox area */}
|
{/* Logo/checkbox area */}
|
||||||
<div className={styles.logoMark}>
|
<div className={styles.logoMark}>
|
||||||
{hasError ? (
|
{hasError ? (
|
||||||
<div className={styles.errorPill}>
|
<div className={styles.errorPill}>
|
||||||
<span>{t('error._value', 'Error')}</span>
|
<span>{t("error._value", "Error")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : isSupported ? (
|
) : isSupported ? (
|
||||||
<CheckboxIndicator
|
<CheckboxIndicator
|
||||||
@@ -397,9 +396,7 @@ const FileEditorThumbnail = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.unsupportedPill}>
|
<div className={styles.unsupportedPill}>
|
||||||
<span>
|
<span>{t("unsupported", "Unsupported")}</span>
|
||||||
{t('unsupported', 'Unsupported')}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -412,9 +409,9 @@ const FileEditorThumbnail = ({
|
|||||||
{/* Action buttons group */}
|
{/* Action buttons group */}
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
{isEncrypted && (
|
{isEncrypted && (
|
||||||
<Tooltip label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}>
|
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
aria-label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}
|
aria-label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
className={styles.headerIconButton}
|
className={styles.headerIconButton}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -427,9 +424,17 @@ const FileEditorThumbnail = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Pin/Unpin icon */}
|
{/* Pin/Unpin icon */}
|
||||||
<Tooltip label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}>
|
<Tooltip
|
||||||
|
label={
|
||||||
|
isPinned ? t("unpin", "Unpin File (replace after tool run)") : t("pin", "Pin File (keep active after tool run)")
|
||||||
|
}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
aria-label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}
|
aria-label={
|
||||||
|
isPinned
|
||||||
|
? t("unpin", "Unpin File (replace after tool run)")
|
||||||
|
: t("pin", "Pin File (keep active after tool run)")
|
||||||
|
}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
className={isPinned ? styles.pinned : styles.headerIconButton}
|
className={isPinned ? styles.pinned : styles.headerIconButton}
|
||||||
data-tour="file-card-pin"
|
data-tour="file-card-pin"
|
||||||
@@ -438,10 +443,10 @@ const FileEditorThumbnail = ({
|
|||||||
if (actualFile) {
|
if (actualFile) {
|
||||||
if (isPinned) {
|
if (isPinned) {
|
||||||
unpinFile(actualFile);
|
unpinFile(actualFile);
|
||||||
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
alert({ alertType: "neutral", title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||||
} else {
|
} else {
|
||||||
pinFile(actualFile);
|
pinFile(actualFile);
|
||||||
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
alert({ alertType: "success", title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -454,35 +459,30 @@ const FileEditorThumbnail = ({
|
|||||||
|
|
||||||
{/* Title + meta line */}
|
{/* Title + meta line */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '0.5rem',
|
padding: "0.5rem",
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
background: 'var(--file-card-bg)',
|
background: "var(--file-card-bg)",
|
||||||
marginTop: '0.5rem',
|
marginTop: "0.5rem",
|
||||||
marginBottom: '0.5rem',
|
marginBottom: "0.5rem",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Text size="lg" fw={700} className={styles.title} title={file.name}>
|
<Text size="lg" fw={700} className={styles.title} title={file.name}>
|
||||||
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text size="sm" c="dimmed" className={styles.meta} lineClamp={3} title={`${extUpper || "FILE"} • ${prettySize}`}>
|
||||||
size="sm"
|
|
||||||
c="dimmed"
|
|
||||||
className={styles.meta}
|
|
||||||
lineClamp={3}
|
|
||||||
title={`${extUpper || 'FILE'} • ${prettySize}`}
|
|
||||||
>
|
|
||||||
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
|
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
|
||||||
{`v${file.versionNumber} - `}
|
{`v${file.versionNumber} - `}
|
||||||
{dateLabel}
|
{dateLabel}
|
||||||
{extUpper ? ` - ${extUpper} file` : ''}
|
{extUpper ? ` - ${extUpper} file` : ""}
|
||||||
{pageLabel ? ` - ${pageLabel}` : ''}
|
{pageLabel ? ` - ${pageLabel}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview area */}
|
{/* Preview area */}
|
||||||
<div
|
<div
|
||||||
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
|
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
|
||||||
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
|
style={isSupported || hasError ? undefined : { filter: "grayscale(80%)", opacity: 0.6 }}
|
||||||
>
|
>
|
||||||
<div className={styles.previewPaper}>
|
<div className={styles.previewPaper}>
|
||||||
{file.thumbnailUrl ? (
|
{file.thumbnailUrl ? (
|
||||||
@@ -495,27 +495,29 @@ const FileEditorThumbnail = ({
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
img.style.display = 'none';
|
img.style.display = "none";
|
||||||
img.parentElement?.setAttribute('data-thumb-missing', 'true');
|
img.parentElement?.setAttribute("data-thumb-missing", "true");
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '80%',
|
maxWidth: "80%",
|
||||||
maxHeight: '80%',
|
maxHeight: "80%",
|
||||||
objectFit: 'contain',
|
objectFit: "contain",
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
background: '#ffffff',
|
background: "#ffffff",
|
||||||
border: '1px solid var(--border-default)',
|
border: "1px solid var(--border-default)",
|
||||||
display: 'block',
|
display: "block",
|
||||||
marginLeft: 'auto',
|
marginLeft: "auto",
|
||||||
marginRight: 'auto',
|
marginRight: "auto",
|
||||||
alignSelf: 'start'
|
alignSelf: "start",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PrivateContent>
|
</PrivateContent>
|
||||||
) : file.type?.startsWith('application/pdf') ? (
|
) : file.type?.startsWith("application/pdf") ? (
|
||||||
<Stack align="center" justify="center" gap="xs" style={{ height: '100%' }}>
|
<Stack align="center" justify="center" gap="xs" style={{ height: "100%" }}>
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
<Text size="xs" c="dimmed">Loading thumbnail...</Text>
|
<Text size="xs" c="dimmed">
|
||||||
|
Loading thumbnail...
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -527,74 +529,72 @@ const FileEditorThumbnail = ({
|
|||||||
|
|
||||||
{/* Tool chain display at bottom */}
|
{/* Tool chain display at bottom */}
|
||||||
{file.toolHistory && (
|
{file.toolHistory && (
|
||||||
<div style={{
|
<div
|
||||||
position: 'absolute',
|
style={{
|
||||||
bottom: '4px',
|
position: "absolute",
|
||||||
left: '4px',
|
bottom: "4px",
|
||||||
right: '4px',
|
left: "4px",
|
||||||
padding: '4px 6px',
|
right: "4px",
|
||||||
textAlign: 'center',
|
padding: "4px 6px",
|
||||||
fontWeight: 600,
|
textAlign: "center",
|
||||||
overflow: 'hidden',
|
fontWeight: 600,
|
||||||
whiteSpace: 'nowrap'
|
overflow: "hidden",
|
||||||
}}>
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ToolChain
|
<ToolChain
|
||||||
toolChain={file.toolHistory}
|
toolChain={file.toolHistory}
|
||||||
displayStyle="text"
|
displayStyle="text"
|
||||||
size="xs"
|
size="xs"
|
||||||
maxWidth={'100%'}
|
maxWidth={"100%"}
|
||||||
color='var(--mantine-color-gray-7)'
|
color="var(--mantine-color-gray-7)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover Menu */}
|
{/* Hover Menu */}
|
||||||
<HoverActionMenu
|
<HoverActionMenu show={showHoverMenu || isMobile} actions={hoverActions} position="outside" />
|
||||||
show={showHoverMenu || isMobile}
|
|
||||||
actions={hoverActions}
|
|
||||||
position="outside"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Close Confirmation Modal */}
|
{/* Close Confirmation Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
opened={showCloseModal}
|
opened={showCloseModal}
|
||||||
onClose={handleCancelClose}
|
onClose={handleCancelClose}
|
||||||
title={t('confirmClose', 'Confirm Close')}
|
title={t("confirmClose", "Confirm Close")}
|
||||||
centered
|
centered
|
||||||
size="auto"
|
size="auto"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{file.isDirty && file.localFilePath ? (
|
{file.isDirty && file.localFilePath ? (
|
||||||
<>
|
<>
|
||||||
<Text size="md">{t('confirmCloseUnsaved', 'This file has unsaved changes.')}</Text>
|
<Text size="md">{t("confirmCloseUnsaved", "This file has unsaved changes.")}</Text>
|
||||||
<Text size="sm" c="dimmed" fw={500}>
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
{file.name}
|
{file.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="light" onClick={handleCancelClose}>
|
<Button variant="light" onClick={handleCancelClose}>
|
||||||
{t('confirmCloseCancel', 'Cancel')}
|
{t("confirmCloseCancel", "Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||||
{t('confirmCloseDiscard', 'Discard changes and close')}
|
{t("confirmCloseDiscard", "Discard changes and close")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="filled" onClick={handleSaveAndClose}>
|
<Button variant="filled" onClick={handleSaveAndClose}>
|
||||||
{t('confirmCloseSave', 'Save and close')}
|
{t("confirmCloseSave", "Save and close")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
|
<Text size="md">{t("confirmCloseMessage", "Are you sure you want to close this file?")}</Text>
|
||||||
<Text size="sm" c="dimmed" fw={500}>
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
{file.name}
|
{file.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="light" onClick={handleCancelClose}>
|
<Button variant="light" onClick={handleCancelClose}>
|
||||||
{t('confirmCloseCancel', 'Cancel')}
|
{t("confirmCloseCancel", "Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||||
{t('confirmCloseConfirm', 'Close File')}
|
{t("confirmCloseConfirm", "Close File")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
@@ -604,39 +604,27 @@ const FileEditorThumbnail = ({
|
|||||||
<Modal
|
<Modal
|
||||||
opened={showSharedEditNotice}
|
opened={showSharedEditNotice}
|
||||||
onClose={() => setShowSharedEditNotice(false)}
|
onClose={() => setShowSharedEditNotice(false)}
|
||||||
title={t('fileManager.sharedEditNoticeTitle', 'Read-only server copy')}
|
title={t("fileManager.sharedEditNoticeTitle", "Read-only server copy")}
|
||||||
centered
|
centered
|
||||||
size="auto"
|
size="auto"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{t(
|
{t(
|
||||||
'fileManager.sharedEditNoticeBody',
|
"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.'
|
"You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.",
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button onClick={() => setShowSharedEditNotice(false)}>
|
<Button onClick={() => setShowSharedEditNotice(false)}>
|
||||||
{t('fileManager.sharedEditNoticeConfirm', 'Got it')}
|
{t("fileManager.sharedEditNoticeConfirm", "Got it")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{canUpload && (
|
{canUpload && <UploadToServerModal opened={showUploadModal} onClose={() => setShowUploadModal(false)} file={file} />}
|
||||||
<UploadToServerModal
|
{canShare && <ShareFileModal opened={showShareModal} onClose={() => setShowShareModal(false)} file={file} />}
|
||||||
opened={showUploadModal}
|
|
||||||
onClose={() => setShowUploadModal(false)}
|
|
||||||
file={file}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canShare && (
|
|
||||||
<ShareFileModal
|
|
||||||
opened={showShareModal}
|
|
||||||
onClose={() => setShowShareModal(false)}
|
|
||||||
file={file}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { useRightRailButtons, RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
|
import { useRightRailButtons, RightRailButtonWithAction } from "@app/hooks/useRightRailButtons";
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
|
|
||||||
interface FileEditorRightRailButtonsParams {
|
interface FileEditorRightRailButtonsParams {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
@@ -20,41 +20,44 @@ export function useFileEditorRightRailButtons({
|
|||||||
}: FileEditorRightRailButtonsParams) {
|
}: FileEditorRightRailButtonsParams) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => [
|
const buttons = useMemo<RightRailButtonWithAction[]>(
|
||||||
{
|
() => [
|
||||||
id: 'file-select-all',
|
{
|
||||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
id: "file-select-all",
|
||||||
tooltip: t('rightRail.selectAll', 'Select All'),
|
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||||
ariaLabel: typeof t === 'function' ? t('rightRail.selectAll', 'Select All') : 'Select All',
|
tooltip: t("rightRail.selectAll", "Select All"),
|
||||||
section: 'top' as const,
|
ariaLabel: typeof t === "function" ? t("rightRail.selectAll", "Select All") : "Select All",
|
||||||
order: 10,
|
section: "top" as const,
|
||||||
disabled: totalItems === 0 || selectedCount === totalItems,
|
order: 10,
|
||||||
visible: totalItems > 0,
|
disabled: totalItems === 0 || selectedCount === totalItems,
|
||||||
onClick: onSelectAll,
|
visible: totalItems > 0,
|
||||||
},
|
onClick: onSelectAll,
|
||||||
{
|
},
|
||||||
id: 'file-deselect-all',
|
{
|
||||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
id: "file-deselect-all",
|
||||||
tooltip: t('rightRail.deselectAll', 'Deselect All'),
|
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||||
ariaLabel: typeof t === 'function' ? t('rightRail.deselectAll', 'Deselect All') : 'Deselect All',
|
tooltip: t("rightRail.deselectAll", "Deselect All"),
|
||||||
section: 'top' as const,
|
ariaLabel: typeof t === "function" ? t("rightRail.deselectAll", "Deselect All") : "Deselect All",
|
||||||
order: 20,
|
section: "top" as const,
|
||||||
disabled: selectedCount === 0,
|
order: 20,
|
||||||
visible: totalItems > 0,
|
disabled: selectedCount === 0,
|
||||||
onClick: onDeselectAll,
|
visible: totalItems > 0,
|
||||||
},
|
onClick: onDeselectAll,
|
||||||
{
|
},
|
||||||
id: 'file-close-selected',
|
{
|
||||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
id: "file-close-selected",
|
||||||
tooltip: t('rightRail.closeSelected', 'Close Selected Files'),
|
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
ariaLabel: typeof t === 'function' ? t('rightRail.closeSelected', 'Close Selected Files') : 'Close Selected Files',
|
tooltip: t("rightRail.closeSelected", "Close Selected Files"),
|
||||||
section: 'top' as const,
|
ariaLabel: typeof t === "function" ? t("rightRail.closeSelected", "Close Selected Files") : "Close Selected Files",
|
||||||
order: 30,
|
section: "top" as const,
|
||||||
disabled: selectedCount === 0,
|
order: 30,
|
||||||
visible: totalItems > 0,
|
disabled: selectedCount === 0,
|
||||||
onClick: onCloseSelected,
|
visible: totalItems > 0,
|
||||||
},
|
onClick: onCloseSelected,
|
||||||
], [t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected]);
|
},
|
||||||
|
],
|
||||||
|
[t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected],
|
||||||
|
);
|
||||||
|
|
||||||
useRightRailButtons(buttons);
|
useRightRailButtons(buttons);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Stack, Box, Text, Button, ActionIcon, Center } from '@mantine/core';
|
import { Stack, Box, Text, Button, ActionIcon, Center } from "@mantine/core";
|
||||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { getFileSize } from '@app/utils/fileUtils';
|
import { getFileSize } from "@app/utils/fileUtils";
|
||||||
import { StirlingFileStub } from '@app/types/fileContext';
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||||
|
|
||||||
interface CompactFileDetailsProps {
|
interface CompactFileDetailsProps {
|
||||||
currentFile: StirlingFileStub | null;
|
currentFile: StirlingFileStub | null;
|
||||||
@@ -29,47 +29,56 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
isAnimating,
|
isAnimating,
|
||||||
onPrevious,
|
onPrevious,
|
||||||
onNext,
|
onNext,
|
||||||
onOpenFiles
|
onOpenFiles,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hasSelection = selectedFiles.length > 0;
|
const hasSelection = selectedFiles.length > 0;
|
||||||
const hasMultipleFiles = numberOfFiles > 1;
|
const hasMultipleFiles = numberOfFiles > 1;
|
||||||
const showOwner = Boolean(
|
const showOwner = Boolean(
|
||||||
currentFile &&
|
currentFile && (currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink),
|
||||||
(currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink)
|
|
||||||
);
|
);
|
||||||
const ownerLabel = currentFile
|
const ownerLabel = currentFile ? currentFile.remoteOwnerUsername || t("fileManager.ownerUnknown", "Unknown") : "";
|
||||||
? currentFile.remoteOwnerUsername || t('fileManager.ownerUnknown', 'Unknown')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs" style={{ height: '100%' }}>
|
<Stack gap="xs" style={{ height: "100%" }}>
|
||||||
{/* Compact mobile layout */}
|
{/* Compact mobile layout */}
|
||||||
<Box style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
<Box style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
|
||||||
{/* Small preview */}
|
{/* Small preview */}
|
||||||
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Box
|
||||||
|
style={{
|
||||||
|
width: "7.5rem",
|
||||||
|
height: "9.375rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{currentFile && thumbnail ? (
|
{currentFile && thumbnail ? (
|
||||||
<PrivateContent>
|
<PrivateContent>
|
||||||
<img
|
<img
|
||||||
src={thumbnail}
|
src={thumbnail}
|
||||||
alt={currentFile.name}
|
alt={currentFile.name}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: "100%",
|
||||||
maxHeight: '100%',
|
maxHeight: "100%",
|
||||||
objectFit: 'contain',
|
objectFit: "contain",
|
||||||
borderRadius: '0.25rem',
|
borderRadius: "0.25rem",
|
||||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
|
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PrivateContent>
|
</PrivateContent>
|
||||||
) : currentFile ? (
|
) : currentFile ? (
|
||||||
<Center style={{
|
<Center
|
||||||
width: '100%',
|
style={{
|
||||||
height: '100%',
|
width: "100%",
|
||||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
height: "100%",
|
||||||
borderRadius: 4
|
backgroundColor: "var(--mantine-color-gray-1)",
|
||||||
}}>
|
borderRadius: 4,
|
||||||
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
|
}}
|
||||||
|
>
|
||||||
|
<PictureAsPdfIcon style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }} />
|
||||||
</Center>
|
</Center>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -77,10 +86,10 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
{/* File info */}
|
{/* File info */}
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text size="sm" fw={500} truncate>
|
<Text size="sm" fw={500} truncate>
|
||||||
<PrivateContent>{currentFile ? currentFile.name : 'No file selected'}</PrivateContent>
|
<PrivateContent>{currentFile ? currentFile.name : "No file selected"}</PrivateContent>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{currentFile ? getFileSize(currentFile) : ''}
|
{currentFile ? getFileSize(currentFile) : ""}
|
||||||
{selectedFiles.length > 1 && ` • ${selectedFiles.length} files`}
|
{selectedFiles.length > 1 && ` • ${selectedFiles.length} files`}
|
||||||
{currentFile && ` • v${currentFile.versionNumber || 1}`}
|
{currentFile && ` • v${currentFile.versionNumber || 1}`}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -92,33 +101,23 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
{/* Compact tool chain for mobile */}
|
{/* Compact tool chain for mobile */}
|
||||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(' → ')}
|
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(" → ")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{currentFile && showOwner && (
|
{currentFile && showOwner && (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{t('fileManager.owner', 'Owner')}: {ownerLabel}
|
{t("fileManager.owner", "Owner")}: {ownerLabel}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Navigation arrows for multiple files */}
|
{/* Navigation arrows for multiple files */}
|
||||||
{hasMultipleFiles && (
|
{hasMultipleFiles && (
|
||||||
<Box style={{ display: 'flex', gap: '0.25rem' }}>
|
<Box style={{ display: "flex", gap: "0.25rem" }}>
|
||||||
<ActionIcon
|
<ActionIcon variant="subtle" size="sm" onClick={onPrevious} disabled={isAnimating}>
|
||||||
variant="subtle"
|
|
||||||
size="sm"
|
|
||||||
onClick={onPrevious}
|
|
||||||
disabled={isAnimating}
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon style={{ fontSize: 16 }} />
|
<ChevronLeftIcon style={{ fontSize: 16 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon variant="subtle" size="sm" onClick={onNext} disabled={isAnimating}>
|
||||||
variant="subtle"
|
|
||||||
size="sm"
|
|
||||||
onClick={onNext}
|
|
||||||
disabled={isAnimating}
|
|
||||||
>
|
|
||||||
<ChevronRightIcon style={{ fontSize: 16 }} />
|
<ChevronRightIcon style={{ fontSize: 16 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -132,14 +131,13 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
disabled={!hasSelection}
|
disabled={!hasSelection}
|
||||||
fullWidth
|
fullWidth
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
|
||||||
color: 'white'
|
color: "white",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedFiles.length > 1
|
{selectedFiles.length > 1
|
||||||
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||||
: t('fileManager.openFile', 'Open File')
|
: t("fileManager.openFile", "Open File")}
|
||||||
}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,79 +1,85 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Grid } from '@mantine/core';
|
import { Grid } from "@mantine/core";
|
||||||
import FileSourceButtons from '@app/components/fileManager/FileSourceButtons';
|
import FileSourceButtons from "@app/components/fileManager/FileSourceButtons";
|
||||||
import FileDetails from '@app/components/fileManager/FileDetails';
|
import FileDetails from "@app/components/fileManager/FileDetails";
|
||||||
import SearchInput from '@app/components/fileManager/SearchInput';
|
import SearchInput from "@app/components/fileManager/SearchInput";
|
||||||
import FileListArea from '@app/components/fileManager/FileListArea';
|
import FileListArea from "@app/components/fileManager/FileListArea";
|
||||||
import FileActions from '@app/components/fileManager/FileActions';
|
import FileActions from "@app/components/fileManager/FileActions";
|
||||||
import HiddenFileInput from '@app/components/fileManager/HiddenFileInput';
|
import HiddenFileInput from "@app/components/fileManager/HiddenFileInput";
|
||||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||||
|
|
||||||
const DesktopLayout: React.FC = () => {
|
const DesktopLayout: React.FC = () => {
|
||||||
const {
|
const { activeSource, recentFiles, modalHeight } = useFileManagerContext();
|
||||||
activeSource,
|
|
||||||
recentFiles,
|
|
||||||
modalHeight,
|
|
||||||
} = useFileManagerContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: 'nowrap', minWidth: 0 }}>
|
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: "nowrap", minWidth: 0 }}>
|
||||||
{/* Column 1: File Sources */}
|
{/* Column 1: File Sources */}
|
||||||
<Grid.Col span="content" p="lg" style={{
|
<Grid.Col
|
||||||
minWidth: '13.625rem',
|
span="content"
|
||||||
width: '13.625rem',
|
p="lg"
|
||||||
flexShrink: 0,
|
style={{
|
||||||
height: '100%',
|
minWidth: "13.625rem",
|
||||||
}} data-tour="file-sources">
|
width: "13.625rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
data-tour="file-sources"
|
||||||
|
>
|
||||||
<FileSourceButtons />
|
<FileSourceButtons />
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
{/* Column 2: File List */}
|
{/* Column 2: File List */}
|
||||||
<Grid.Col span="auto" style={{
|
<Grid.Col
|
||||||
display: 'flex',
|
span="auto"
|
||||||
flexDirection: 'column',
|
style={{
|
||||||
height: '100%',
|
display: "flex",
|
||||||
minHeight: 0,
|
flexDirection: "column",
|
||||||
minWidth: 0,
|
height: "100%",
|
||||||
flex: '1 1 0px'
|
minHeight: 0,
|
||||||
}}>
|
minWidth: 0,
|
||||||
<div style={{
|
flex: "1 1 0px",
|
||||||
flex: 1,
|
}}
|
||||||
display: 'flex',
|
>
|
||||||
flexDirection: 'column',
|
<div
|
||||||
backgroundColor: 'var(--bg-file-list)',
|
style={{
|
||||||
border: '1px solid var(--mantine-color-gray-2)',
|
flex: 1,
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
display: "flex",
|
||||||
overflow: 'hidden'
|
flexDirection: "column",
|
||||||
}}>
|
backgroundColor: "var(--bg-file-list)",
|
||||||
{activeSource === 'recent' && (
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeSource === "recent" && (
|
||||||
<>
|
<>
|
||||||
<div style={{
|
<div
|
||||||
flexShrink: 0,
|
style={{
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
flexShrink: 0,
|
||||||
}}>
|
borderBottom: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div
|
||||||
flexShrink: 0,
|
style={{
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
flexShrink: 0,
|
||||||
}}>
|
borderBottom: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<FileActions />
|
<FileActions />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||||
<FileListArea
|
<FileListArea
|
||||||
scrollAreaHeight={activeSource === 'recent' && recentFiles.length > 0
|
scrollAreaHeight={activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%"}
|
||||||
? `calc(${modalHeight} - 7rem)`
|
|
||||||
: '100%'}
|
|
||||||
scrollAreaStyle={{
|
scrollAreaStyle={{
|
||||||
height: activeSource === 'recent' && recentFiles.length > 0
|
height: activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%",
|
||||||
? `calc(${modalHeight} - 7rem)`
|
backgroundColor: "transparent",
|
||||||
: '100%',
|
border: "none",
|
||||||
backgroundColor: 'transparent',
|
borderRadius: 0,
|
||||||
border: 'none',
|
|
||||||
borderRadius: 0
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,14 +87,18 @@ const DesktopLayout: React.FC = () => {
|
|||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
{/* Column 3: File Details */}
|
{/* Column 3: File Details */}
|
||||||
<Grid.Col p="xl" span="content" style={{
|
<Grid.Col
|
||||||
minWidth: '25rem',
|
p="xl"
|
||||||
width: '25rem',
|
span="content"
|
||||||
flexShrink: 0,
|
style={{
|
||||||
height: '100%',
|
minWidth: "25rem",
|
||||||
maxWidth: '18rem'
|
width: "25rem",
|
||||||
}}>
|
flexShrink: 0,
|
||||||
<div style={{ height: '100%', overflow: 'hidden' }}>
|
height: "100%",
|
||||||
|
maxWidth: "18rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ height: "100%", overflow: "hidden" }}>
|
||||||
<FileDetails />
|
<FileDetails />
|
||||||
</div>
|
</div>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Stack, Text, useMantineTheme, alpha } from '@mantine/core';
|
import { Stack, Text, useMantineTheme, alpha } from "@mantine/core";
|
||||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface DragOverlayProps {
|
interface DragOverlayProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
@@ -16,25 +16,25 @@ const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: alpha(theme.colors.blue[6], 0.1),
|
backgroundColor: alpha(theme.colors.blue[6], 0.1),
|
||||||
border: `0.125rem dashed ${theme.colors.blue[6]}`,
|
border: `0.125rem dashed ${theme.colors.blue[6]}`,
|
||||||
borderRadius: '1.875rem',
|
borderRadius: "1.875rem",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
pointerEvents: 'none'
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack align="center" gap="md">
|
<Stack align="center" gap="md">
|
||||||
<UploadFileIcon style={{ fontSize: '4rem', color: theme.colors.blue[6] }} />
|
<UploadFileIcon style={{ fontSize: "4rem", color: theme.colors.blue[6] }} />
|
||||||
<Text size="xl" fw={500} c="blue.6">
|
<Text size="xl" fw={500} c="blue.6">
|
||||||
{t('fileManager.dropFilesHere', 'Drop files here to upload')}
|
{t("fileManager.dropFilesHere", "Drop files here to upload")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core';
|
import { Button, Group, Text, Stack, useMantineColorScheme } from "@mantine/core";
|
||||||
import HistoryIcon from '@mui/icons-material/History';
|
import HistoryIcon from "@mui/icons-material/History";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
||||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||||
|
|
||||||
const EmptyFilesState: React.FC = () => {
|
const EmptyFilesState: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -24,92 +24,90 @@ const EmptyFilesState: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: '100%',
|
height: "100%",
|
||||||
width: '100%',
|
width: "100%",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
padding: '2rem'
|
padding: "2rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Container */}
|
{/* Container */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: "transparent",
|
||||||
padding: '3rem 2rem',
|
padding: "3rem 2rem",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
gap: '1.5rem',
|
gap: "1.5rem",
|
||||||
minWidth: '20rem',
|
minWidth: "20rem",
|
||||||
maxWidth: '28rem',
|
maxWidth: "28rem",
|
||||||
width: '100%'
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* No Recent Files Message */}
|
{/* No Recent Files Message */}
|
||||||
<Stack align="center" gap="sm">
|
<Stack align="center" gap="sm">
|
||||||
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
<HistoryIcon style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }} />
|
||||||
<Text c="dimmed" ta="center" size="lg">
|
<Text c="dimmed" ta="center" size="lg">
|
||||||
{t('fileManager.noRecentFiles', 'No recent files')}
|
{t("fileManager.noRecentFiles", "No recent files")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Stirling PDF Logo */}
|
{/* Stirling PDF Logo */}
|
||||||
<Group gap="xs" align="center">
|
<Group gap="xs" align="center">
|
||||||
<img
|
<img
|
||||||
src={colorScheme === 'dark' ? wordmark.white : wordmark.grey}
|
src={colorScheme === "dark" ? wordmark.white : wordmark.grey}
|
||||||
alt="Stirling PDF"
|
alt="Stirling PDF"
|
||||||
style={{ height: '2.2rem', width: 'auto' }}
|
style={{ height: "2.2rem", width: "auto" }}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Upload Button */}
|
{/* Upload Button */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
width: '100%',
|
width: "100%",
|
||||||
marginTop: '0.5rem',
|
marginTop: "0.5rem",
|
||||||
marginBottom: '0.5rem'
|
marginBottom: "0.5rem",
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => setIsUploadHover(false)}
|
onMouseLeave={() => setIsUploadHover(false)}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Upload"
|
aria-label="Upload"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--bg-file-manager)',
|
backgroundColor: "var(--bg-file-manager)",
|
||||||
color: 'var(--landing-button-color)',
|
color: "var(--landing-button-color)",
|
||||||
border: '1px solid var(--landing-button-border)',
|
border: "1px solid var(--landing-button-border)",
|
||||||
borderRadius: isUploadHover ? '2rem' : '1rem',
|
borderRadius: isUploadHover ? "2rem" : "1rem",
|
||||||
height: '38px',
|
height: "38px",
|
||||||
width: isUploadHover ? '100%' : '58px',
|
width: isUploadHover ? "100%" : "58px",
|
||||||
minWidth: '58px',
|
minWidth: "58px",
|
||||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
paddingLeft: isUploadHover ? "1rem" : 0,
|
||||||
paddingRight: isUploadHover ? '1rem' : 0,
|
paddingRight: isUploadHover ? "1rem" : 0,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
transition: 'width .5s ease, padding .5s ease, border-radius .5s ease'
|
transition: "width .5s ease, padding .5s ease, border-radius .5s ease",
|
||||||
}}
|
}}
|
||||||
onClick={handleUploadClick}
|
onClick={handleUploadClick}
|
||||||
onMouseEnter={() => setIsUploadHover(true)}
|
onMouseEnter={() => setIsUploadHover(true)}
|
||||||
>
|
>
|
||||||
<LocalIcon icon={icons.uploadIconName} width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
<LocalIcon
|
||||||
{isUploadHover && (
|
icon={icons.uploadIconName}
|
||||||
<span style={{ marginLeft: '.5rem' }}>
|
width="1.25rem"
|
||||||
{terminology.uploadFromComputer}
|
height="1.25rem"
|
||||||
</span>
|
style={{ color: "var(--accent-interactive)" }}
|
||||||
)}
|
/>
|
||||||
|
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Instruction Text */}
|
{/* Instruction Text */}
|
||||||
<span
|
<span className="text-[var(--accent-interactive)]" style={{ fontSize: ".8rem", textAlign: "center" }}>
|
||||||
className="text-[var(--accent-interactive)]"
|
|
||||||
style={{ fontSize: '.8rem', textAlign: 'center' }}
|
|
||||||
>
|
|
||||||
{terminology.dropFilesHere}
|
{terminology.dropFilesHere}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,9 +30,8 @@ const FileActions: React.FC = () => {
|
|||||||
onDownloadSelected,
|
onDownloadSelected,
|
||||||
refreshRecentFiles,
|
refreshRecentFiles,
|
||||||
storageFilter,
|
storageFilter,
|
||||||
onStorageFilterChange
|
onStorageFilterChange,
|
||||||
} =
|
} = useFileManagerContext();
|
||||||
useFileManagerContext();
|
|
||||||
const uploadEnabled = config?.storageEnabled === true;
|
const uploadEnabled = config?.storageEnabled === true;
|
||||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||||
@@ -42,11 +41,11 @@ const FileActions: React.FC = () => {
|
|||||||
{ value: "all", label: t("fileManager.filterAll", "All") },
|
{ value: "all", label: t("fileManager.filterAll", "All") },
|
||||||
{ value: "local", label: t("fileManager.filterLocal", "Local") },
|
{ value: "local", label: t("fileManager.filterLocal", "Local") },
|
||||||
{ value: "sharedWithMe", label: t("fileManager.filterSharedWithMe", "Shared with me") },
|
{ 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: "all", label: t("fileManager.filterAll", "All") },
|
||||||
{ value: "local", label: t("fileManager.filterLocal", "Local") }
|
{ value: "local", label: t("fileManager.filterLocal", "Local") },
|
||||||
];
|
];
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) {
|
if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) {
|
||||||
@@ -56,10 +55,8 @@ const FileActions: React.FC = () => {
|
|||||||
const hasSelection = selectedFileIds.length > 0;
|
const hasSelection = selectedFileIds.length > 0;
|
||||||
const hasOnlyOwnedSelection = selectedFiles.every((file) => file.remoteOwnedByCurrentUser !== false);
|
const hasOnlyOwnedSelection = selectedFiles.every((file) => file.remoteOwnedByCurrentUser !== false);
|
||||||
const hasDownloadAccess = selectedFiles.every((file) => {
|
const hasDownloadAccess = selectedFiles.every((file) => {
|
||||||
const role = (file.remoteOwnedByCurrentUser !== false
|
const role = (file.remoteOwnedByCurrentUser !== false ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase();
|
||||||
? 'editor'
|
return role === "editor" || role === "commenter" || role === "viewer";
|
||||||
: (file.remoteAccessRole ?? 'viewer')).toLowerCase();
|
|
||||||
return role === 'editor' || role === 'commenter' || role === 'viewer';
|
|
||||||
});
|
});
|
||||||
const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection;
|
const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection;
|
||||||
const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
|
const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
|
||||||
@@ -80,7 +77,6 @@ const FileActions: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Only show actions if there are files
|
// Only show actions if there are files
|
||||||
if (recentFiles.length === 0) {
|
if (recentFiles.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -120,9 +116,7 @@ const FileActions: React.FC = () => {
|
|||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
size="xs"
|
size="xs"
|
||||||
value={storageFilter}
|
value={storageFilter}
|
||||||
onChange={(value) =>
|
onChange={(value) => onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")}
|
||||||
onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")
|
|
||||||
}
|
|
||||||
data={storageFilterOptions}
|
data={storageFilterOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from "react";
|
||||||
import { Stack, Button, Box } from '@mantine/core';
|
import { Stack, Button, Box } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { useIndexedDBThumbnail } from '@app/hooks/useIndexedDBThumbnail';
|
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
|
||||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||||
import FilePreview from '@app/components/shared/FilePreview';
|
import FilePreview from "@app/components/shared/FilePreview";
|
||||||
import FileInfoCard from '@app/components/fileManager/FileInfoCard';
|
import FileInfoCard from "@app/components/fileManager/FileInfoCard";
|
||||||
import CompactFileDetails from '@app/components/fileManager/CompactFileDetails';
|
import CompactFileDetails from "@app/components/fileManager/CompactFileDetails";
|
||||||
|
|
||||||
interface FileDetailsProps {
|
interface FileDetailsProps {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileDetails: React.FC<FileDetailsProps> = ({
|
const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||||
compact = false
|
|
||||||
}) => {
|
|
||||||
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
|
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||||
@@ -35,7 +33,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
|||||||
if (isAnimating) return;
|
if (isAnimating) return;
|
||||||
setIsAnimating(true);
|
setIsAnimating(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCurrentFileIndex(prev => prev > 0 ? prev - 1 : selectedFiles.length - 1);
|
setCurrentFileIndex((prev) => (prev > 0 ? prev - 1 : selectedFiles.length - 1));
|
||||||
setIsAnimating(false);
|
setIsAnimating(false);
|
||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
@@ -44,7 +42,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
|||||||
if (isAnimating) return;
|
if (isAnimating) return;
|
||||||
setIsAnimating(true);
|
setIsAnimating(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCurrentFileIndex(prev => prev < selectedFiles.length - 1 ? prev + 1 : 0);
|
setCurrentFileIndex((prev) => (prev < selectedFiles.length - 1 ? prev + 1 : 0));
|
||||||
setIsAnimating(false);
|
setIsAnimating(false);
|
||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
@@ -75,7 +73,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`} justify="flex-start">
|
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`} justify="flex-start">
|
||||||
{/* Section 1: Thumbnail Preview */}
|
{/* Section 1: Thumbnail Preview */}
|
||||||
<Box style={{ width: '100%', height: 'min(35vh, 280px)', textAlign: 'center', flexShrink: 0 }}>
|
<Box style={{ width: "100%", height: "min(35vh, 280px)", textAlign: "center", flexShrink: 0 }}>
|
||||||
<FilePreview
|
<FilePreview
|
||||||
file={currentFile}
|
file={currentFile}
|
||||||
thumbnail={getCurrentThumbnail()}
|
thumbnail={getCurrentThumbnail()}
|
||||||
@@ -89,10 +87,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Section 2: File Details */}
|
{/* Section 2: File Details */}
|
||||||
<FileInfoCard
|
<FileInfoCard currentFile={currentFile} modalHeight={modalHeight} />
|
||||||
currentFile={currentFile}
|
|
||||||
modalHeight={modalHeight}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
@@ -100,14 +95,13 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
|||||||
disabled={!hasSelection}
|
disabled={!hasSelection}
|
||||||
fullWidth
|
fullWidth
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
|
||||||
color: 'white'
|
color: "white",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedFiles.length > 1
|
{selectedFiles.length > 1
|
||||||
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||||
: t('fileManager.openFile', 'Open File')
|
: t("fileManager.openFile", "Open File")}
|
||||||
}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Box, Text, Collapse, Group } from '@mantine/core';
|
import { Box, Text, Collapse, Group } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { StirlingFileStub } from '@app/types/fileContext';
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import FileListItem from '@app/components/fileManager/FileListItem';
|
import FileListItem from "@app/components/fileManager/FileListItem";
|
||||||
|
|
||||||
interface FileHistoryGroupProps {
|
interface FileHistoryGroupProps {
|
||||||
leafFile: StirlingFileStub;
|
leafFile: StirlingFileStub;
|
||||||
@@ -27,7 +27,7 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
|||||||
|
|
||||||
// Sort history files by version number (oldest first, excluding the current leaf file)
|
// Sort history files by version number (oldest first, excluding the current leaf file)
|
||||||
const sortedHistory = historyFiles
|
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));
|
.sort((a, b) => (b.versionNumber || 1) - (a.versionNumber || 1));
|
||||||
|
|
||||||
if (!isExpanded || sortedHistory.length === 0) {
|
if (!isExpanded || sortedHistory.length === 0) {
|
||||||
@@ -39,7 +39,7 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
|||||||
<Box ml="md" mt="xs" mb="sm">
|
<Box ml="md" mt="xs" mb="sm">
|
||||||
<Group align="center" mb="sm">
|
<Group align="center" mb="sm">
|
||||||
<Text size="xs" fw={600} c="dimmed">
|
<Text size="xs" fw={600} c="dimmed">
|
||||||
{t('fileManager.fileHistory', 'File History')} ({sortedHistory.length})
|
{t("fileManager.fileHistory", "File History")} ({sortedHistory.length})
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from "react";
|
||||||
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea, Button } from '@mantine/core';
|
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea, Button } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { detectFileExtension, getFileSize } from '@app/utils/fileUtils';
|
import { detectFileExtension, getFileSize } from "@app/utils/fileUtils";
|
||||||
import { StirlingFileStub } from '@app/types/fileContext';
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import ToolChain from '@app/components/shared/ToolChain';
|
import ToolChain from "@app/components/shared/ToolChain";
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||||
import ShareManagementModal from '@app/components/shared/ShareManagementModal';
|
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
|
|
||||||
interface FileInfoCardProps {
|
interface FileInfoCardProps {
|
||||||
currentFile: StirlingFileStub | null;
|
currentFile: StirlingFileStub | null;
|
||||||
modalHeight: string;
|
modalHeight: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight }) => {
|
||||||
currentFile,
|
|
||||||
modalHeight
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
const { onMakeCopy } = useFileManagerContext();
|
const { onMakeCopy } = useFileManagerContext();
|
||||||
@@ -43,38 +40,53 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
const uploadEnabled = config?.storageEnabled === true;
|
const uploadEnabled = config?.storageEnabled === true;
|
||||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||||
const ownerLabel = useMemo(() => {
|
const ownerLabel = useMemo(() => {
|
||||||
if (!currentFile) return '';
|
if (!currentFile) return "";
|
||||||
if (currentFile.remoteOwnerUsername) {
|
if (currentFile.remoteOwnerUsername) {
|
||||||
return currentFile.remoteOwnerUsername;
|
return currentFile.remoteOwnerUsername;
|
||||||
}
|
}
|
||||||
return t('fileManager.ownerUnknown', 'Unknown');
|
return t("fileManager.ownerUnknown", "Unknown");
|
||||||
}, [currentFile, t]);
|
}, [currentFile, t]);
|
||||||
const lastSyncedLabel = useMemo(() => {
|
const lastSyncedLabel = useMemo(() => {
|
||||||
if (!currentFile?.remoteStorageUpdatedAt) return '';
|
if (!currentFile?.remoteStorageUpdatedAt) return "";
|
||||||
return new Date(currentFile.remoteStorageUpdatedAt).toLocaleString();
|
return new Date(currentFile.remoteStorageUpdatedAt).toLocaleString();
|
||||||
}, [currentFile?.remoteStorageUpdatedAt]);
|
}, [currentFile?.remoteStorageUpdatedAt]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder p={0} mah={`calc(${modalHeight} * 0.45)`} style={{ overflow: 'hidden', flexShrink: 1, display: 'flex', flexDirection: 'column' }}>
|
<Card
|
||||||
<Box bg="gray.4" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)', flexShrink: 0 }}>
|
withBorder
|
||||||
|
p={0}
|
||||||
|
mah={`calc(${modalHeight} * 0.45)`}
|
||||||
|
style={{ overflow: "hidden", flexShrink: 1, display: "flex", flexDirection: "column" }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
bg="gray.4"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: "var(--mantine-radius-md)",
|
||||||
|
borderTopRightRadius: "var(--mantine-radius-md)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Text size="sm" fw={500} ta="center" c="white">
|
<Text size="sm" fw={500} ta="center" c="white">
|
||||||
{t('fileManager.details', 'File Details')}
|
{t("fileManager.details", "File Details")}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<ScrollArea style={{ flex: 1, minHeight: 0 }} p="md">
|
<ScrollArea style={{ flex: 1, minHeight: 0 }} p="md">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between" py="xs">
|
<Group justify="space-between" py="xs">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
<PrivateContent>{t('fileManager.fileName', 'Name')}</PrivateContent>
|
<PrivateContent>{t("fileManager.fileName", "Name")}</PrivateContent>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
|
<Text size="sm" fw={500} style={{ maxWidth: "60%", textAlign: "right" }} truncate>
|
||||||
{currentFile ? currentFile.name : ''}
|
{currentFile ? currentFile.name : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Group justify="space-between" py="xs">
|
<Group justify="space-between" py="xs">
|
||||||
<Text size="sm" c="dimmed">{t('fileManager.fileFormat', 'Format')}</Text>
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("fileManager.fileFormat", "Format")}
|
||||||
|
</Text>
|
||||||
{currentFile ? (
|
{currentFile ? (
|
||||||
<Badge size="sm" variant="light">
|
<Badge size="sm" variant="light">
|
||||||
{detectFileExtension(currentFile.name).toUpperCase()}
|
{detectFileExtension(currentFile.name).toUpperCase()}
|
||||||
@@ -86,38 +98,48 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Group justify="space-between" py="xs">
|
<Group justify="space-between" py="xs">
|
||||||
<Text size="sm" c="dimmed">{t('fileManager.fileSize', 'Size')}</Text>
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("fileManager.fileSize", "Size")}
|
||||||
|
</Text>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{currentFile ? getFileSize(currentFile) : ''}
|
{currentFile ? getFileSize(currentFile) : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Group justify="space-between" py="xs">
|
<Group justify="space-between" py="xs">
|
||||||
<Text size="sm" c="dimmed">{t('fileManager.lastModified', 'Last modified')}</Text>
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("fileManager.lastModified", "Last modified")}
|
||||||
|
</Text>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ''}
|
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Group justify="space-between" py="xs">
|
<Group justify="space-between" py="xs">
|
||||||
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
|
<Text size="sm" c="dimmed">
|
||||||
{currentFile &&
|
{t("fileManager.fileVersion", "Version")}
|
||||||
<Badge size="sm" variant="light" color={currentFile?.versionNumber ? 'blue' : 'gray'}>
|
</Text>
|
||||||
v{currentFile ? (currentFile.versionNumber || 1) : ''}
|
{currentFile && (
|
||||||
</Badge>}
|
<Badge size="sm" variant="light" color={currentFile?.versionNumber ? "blue" : "gray"}>
|
||||||
|
v{currentFile ? currentFile.versionNumber || 1 : ""}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{sharingEnabled && isSharedWithYou && (
|
{sharingEnabled && isSharedWithYou && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Group justify="space-between" py="xs">
|
<Group justify="space-between" py="xs">
|
||||||
<Text size="sm" c="dimmed">{t('fileManager.owner', 'Owner')}</Text>
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("fileManager.owner", "Owner")}
|
||||||
|
</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="sm" fw={500}>{ownerLabel}</Text>
|
<Text size="sm" fw={500}>
|
||||||
|
{ownerLabel}
|
||||||
|
</Text>
|
||||||
<Badge size="xs" variant="light" color="grape">
|
<Badge size="xs" variant="light" color="grape">
|
||||||
{t('fileManager.sharedWithYou', 'Shared with you')}
|
{t("fileManager.sharedWithYou", "Shared with you")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -129,12 +151,10 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Box py="xs">
|
<Box py="xs">
|
||||||
<Text size="xs" c="dimmed" mb="xs">{t('fileManager.toolChain', 'Tools Applied')}</Text>
|
<Text size="xs" c="dimmed" mb="xs">
|
||||||
<ToolChain
|
{t("fileManager.toolChain", "Tools Applied")}
|
||||||
toolChain={currentFile.toolHistory}
|
</Text>
|
||||||
displayStyle="badges"
|
<ToolChain toolChain={currentFile.toolHistory} displayStyle="badges" size="xs" />
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -142,13 +162,8 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
{currentFile && isSharedWithYou && (
|
{currentFile && isSharedWithYou && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Button
|
<Button size="sm" variant="light" onClick={() => onMakeCopy(currentFile)} fullWidth>
|
||||||
size="sm"
|
{t("fileManager.makeCopy", "Make a copy")}
|
||||||
variant="light"
|
|
||||||
onClick={() => onMakeCopy(currentFile)}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{t('fileManager.makeCopy', 'Make a copy')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -157,39 +172,42 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Group justify="space-between" py="xs">
|
<Group justify="space-between" py="xs">
|
||||||
<Text size="sm" c="dimmed">{t('fileManager.cloudFile', 'Cloud file')}</Text>
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("fileManager.cloudFile", "Cloud file")}
|
||||||
|
</Text>
|
||||||
{uploadEnabled && isOutOfSync ? (
|
{uploadEnabled && isOutOfSync ? (
|
||||||
<Badge size="xs" variant="light" color="yellow">
|
<Badge size="xs" variant="light" color="yellow">
|
||||||
{t('fileManager.changesNotUploaded', 'Changes not uploaded')}
|
{t("fileManager.changesNotUploaded", "Changes not uploaded")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : uploadEnabled ? (
|
) : uploadEnabled ? (
|
||||||
<Badge size="xs" variant="light" color="teal">
|
<Badge size="xs" variant="light" color="teal">
|
||||||
{t('fileManager.synced', 'Synced')}
|
{t("fileManager.synced", "Synced")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
{lastSyncedLabel && (
|
{lastSyncedLabel && (
|
||||||
<Group justify="space-between" py="xs">
|
<Group justify="space-between" py="xs">
|
||||||
<Text size="sm" c="dimmed">{t('fileManager.lastSynced', 'Last synced')}</Text>
|
<Text size="sm" c="dimmed">
|
||||||
<Text size="sm" fw={500}>{lastSyncedLabel}</Text>
|
{t("fileManager.lastSynced", "Last synced")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{lastSyncedLabel}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
{isSharedByYou && sharingEnabled && (
|
{isSharedByYou && sharingEnabled && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Group justify="space-between" py="xs">
|
<Group justify="space-between" py="xs">
|
||||||
<Text size="sm" c="dimmed">{t('fileManager.sharing', 'Sharing')}</Text>
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("fileManager.sharing", "Sharing")}
|
||||||
|
</Text>
|
||||||
<Badge size="xs" variant="light" color="blue">
|
<Badge size="xs" variant="light" color="blue">
|
||||||
{t('fileManager.sharedByYou', 'Shared by you')}
|
{t("fileManager.sharedByYou", "Shared by you")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
<Button size="sm" variant="light" onClick={() => setShowShareManageModal(true)} fullWidth>
|
||||||
size="sm"
|
{t("storageShare.manage", "Manage sharing")}
|
||||||
variant="light"
|
|
||||||
onClick={() => setShowShareManageModal(true)}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{t('storageShare.manage', 'Manage sharing')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -199,9 +217,11 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Group justify="space-between" py="xs">
|
<Group justify="space-between" py="xs">
|
||||||
<Text size="sm" c="dimmed">{t('fileManager.storageState', 'Storage')}</Text>
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("fileManager.storageState", "Storage")}
|
||||||
|
</Text>
|
||||||
<Badge size="xs" variant="light" color="gray">
|
<Badge size="xs" variant="light" color="gray">
|
||||||
{t('fileManager.localOnly', 'Local only')}
|
{t("fileManager.localOnly", "Local only")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Center, ScrollArea, Text, Stack } from '@mantine/core';
|
import { Center, ScrollArea, Text, Stack } from "@mantine/core";
|
||||||
import CloudIcon from '@mui/icons-material/Cloud';
|
import CloudIcon from "@mui/icons-material/Cloud";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import FileListItem from '@app/components/fileManager/FileListItem';
|
import FileListItem from "@app/components/fileManager/FileListItem";
|
||||||
import FileHistoryGroup from '@app/components/fileManager/FileHistoryGroup';
|
import FileHistoryGroup from "@app/components/fileManager/FileHistoryGroup";
|
||||||
import EmptyFilesState from '@app/components/fileManager/EmptyFilesState';
|
import EmptyFilesState from "@app/components/fileManager/EmptyFilesState";
|
||||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||||
|
|
||||||
interface FileListAreaProps {
|
interface FileListAreaProps {
|
||||||
scrollAreaHeight: string;
|
scrollAreaHeight: string;
|
||||||
scrollAreaStyle?: React.CSSProperties;
|
scrollAreaStyle?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileListArea: React.FC<FileListAreaProps> = ({
|
const FileListArea: React.FC<FileListAreaProps> = ({ scrollAreaHeight, scrollAreaStyle = {} }) => {
|
||||||
scrollAreaHeight,
|
|
||||||
scrollAreaStyle = {},
|
|
||||||
}) => {
|
|
||||||
const {
|
const {
|
||||||
activeSource,
|
activeSource,
|
||||||
recentFiles,
|
recentFiles,
|
||||||
@@ -34,12 +31,12 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
|||||||
} = useFileManagerContext();
|
} = useFileManagerContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (activeSource === 'recent') {
|
if (activeSource === "recent") {
|
||||||
return (
|
return (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
h={scrollAreaHeight}
|
h={scrollAreaHeight}
|
||||||
style={{
|
style={{
|
||||||
...scrollAreaStyle
|
...scrollAreaStyle,
|
||||||
}}
|
}}
|
||||||
type="always"
|
type="always"
|
||||||
scrollbarSize={8}
|
scrollbarSize={8}
|
||||||
@@ -48,8 +45,10 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
|||||||
{recentFiles.length === 0 && !isLoading ? (
|
{recentFiles.length === 0 && !isLoading ? (
|
||||||
<EmptyFilesState />
|
<EmptyFilesState />
|
||||||
) : recentFiles.length === 0 && isLoading ? (
|
) : recentFiles.length === 0 && isLoading ? (
|
||||||
<Center style={{ height: '12.5rem' }}>
|
<Center style={{ height: "12.5rem" }}>
|
||||||
<Text c="dimmed" ta="center">{t('fileManager.loadingFiles', 'Loading files...')}</Text>
|
<Text c="dimmed" ta="center">
|
||||||
|
{t("fileManager.loadingFiles", "Loading files...")}
|
||||||
|
</Text>
|
||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
filteredFiles.map((file, index) => {
|
filteredFiles.map((file, index) => {
|
||||||
@@ -93,10 +92,12 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
|||||||
|
|
||||||
// Google Drive placeholder
|
// Google Drive placeholder
|
||||||
return (
|
return (
|
||||||
<Center style={{ height: '12.5rem' }}>
|
<Center style={{ height: "12.5rem" }}>
|
||||||
<Stack align="center" gap="sm">
|
<Stack align="center" gap="sm">
|
||||||
<CloudIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
<CloudIcon style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }} />
|
||||||
<Text c="dimmed" ta="center">{t('fileManager.googleDriveNotAvailable', 'Google Drive integration coming soon')}</Text>
|
<Text c="dimmed" ta="center">
|
||||||
|
{t("fileManager.googleDriveNotAvailable", "Google Drive integration coming soon")}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core';
|
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from "@mantine/core";
|
||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
import HistoryIcon from '@mui/icons-material/History';
|
import HistoryIcon from "@mui/icons-material/History";
|
||||||
import RestoreIcon from '@mui/icons-material/Restore';
|
import RestoreIcon from "@mui/icons-material/Restore";
|
||||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
import UnarchiveIcon from "@mui/icons-material/Unarchive";
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||||
import CloudDoneIcon from '@mui/icons-material/CloudDone';
|
import CloudDoneIcon from "@mui/icons-material/CloudDone";
|
||||||
import LinkIcon from '@mui/icons-material/Link';
|
import LinkIcon from "@mui/icons-material/Link";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { getFileSize, getFileDate } from '@app/utils/fileUtils';
|
import { getFileSize, getFileDate } from "@app/utils/fileUtils";
|
||||||
import { FileId, StirlingFileStub } from '@app/types/fileContext';
|
import { FileId, StirlingFileStub } from "@app/types/fileContext";
|
||||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||||
import { zipFileService } from '@app/services/zipFileService';
|
import { zipFileService } from "@app/services/zipFileService";
|
||||||
import ToolChain from '@app/components/shared/ToolChain';
|
import ToolChain from "@app/components/shared/ToolChain";
|
||||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||||
import { useFileManagement } from '@app/contexts/FileContext';
|
import { useFileManagement } from "@app/contexts/FileContext";
|
||||||
import UploadToServerModal from '@app/components/shared/UploadToServerModal';
|
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
||||||
import ShareFileModal from '@app/components/shared/ShareFileModal';
|
import ShareFileModal from "@app/components/shared/ShareFileModal";
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import ShareManagementModal from '@app/components/shared/ShareManagementModal';
|
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from "@app/services/apiClient";
|
||||||
import { absoluteWithBasePath } from '@app/constants/app';
|
import { absoluteWithBasePath } from "@app/constants/app";
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from "@app/components/toast";
|
||||||
|
|
||||||
interface FileListItemProps {
|
interface FileListItemProps {
|
||||||
file: StirlingFileStub;
|
file: StirlingFileStub;
|
||||||
@@ -51,7 +51,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
onDoubleClick,
|
onDoubleClick,
|
||||||
isHistoryFile = false,
|
isHistoryFile = false,
|
||||||
isLatestVersion = false,
|
isLatestVersion = false,
|
||||||
isActive = false
|
isActive = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
@@ -60,22 +60,22 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
const [showShareManageModal, setShowShareManageModal] = useState(false);
|
const [showShareManageModal, setShowShareManageModal] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
const {expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext();
|
const { expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext();
|
||||||
const { removeFiles } = useFileManagement();
|
const { removeFiles } = useFileManagement();
|
||||||
|
|
||||||
// Check if this is a ZIP file
|
// Check if this is a ZIP file
|
||||||
const isZipFile = zipFileService.isZipFileStub(file);
|
const isZipFile = zipFileService.isZipFileStub(file);
|
||||||
|
|
||||||
// Check file extension
|
// Check file extension
|
||||||
const extLower = (file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || '').toLowerCase();
|
const extLower = (file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || "").toLowerCase();
|
||||||
const isCBZ = extLower === 'cbz';
|
const isCBZ = extLower === "cbz";
|
||||||
const isCBR = extLower === 'cbr';
|
const isCBR = extLower === "cbr";
|
||||||
|
|
||||||
// Keep item in hovered state if menu is open
|
// Keep item in hovered state if menu is open
|
||||||
const shouldShowHovered = isHovered || isMenuOpen;
|
const shouldShowHovered = isHovered || isMenuOpen;
|
||||||
|
|
||||||
// Get version information for this file
|
// 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 hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
|
||||||
const currentVersion = file.versionNumber || 1; // Display original files as v1
|
const currentVersion = file.versionNumber || 1; // Display original files as v1
|
||||||
const isExpanded = expandedFileIds.has(leafFileId);
|
const isExpanded = expandedFileIds.has(leafFileId);
|
||||||
@@ -83,32 +83,28 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||||
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
|
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
|
||||||
const isSharedWithYou =
|
const isSharedWithYou = sharingEnabled && (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink);
|
||||||
sharingEnabled && (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink);
|
|
||||||
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
|
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
|
||||||
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
|
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
|
||||||
const isUploaded = Boolean(file.remoteStorageId);
|
const isUploaded = Boolean(file.remoteStorageId);
|
||||||
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
||||||
const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal;
|
const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal;
|
||||||
const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink;
|
const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink;
|
||||||
const accessRole = (isOwnedOrLocal ? 'editor' : (file.remoteAccessRole ?? 'viewer')).toLowerCase();
|
const accessRole = (isOwnedOrLocal ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase();
|
||||||
const hasReadAccess = isOwnedOrLocal || accessRole === 'editor' || accessRole === 'commenter' || accessRole === 'viewer';
|
const hasReadAccess = isOwnedOrLocal || accessRole === "editor" || accessRole === "commenter" || accessRole === "viewer";
|
||||||
const canUpload = uploadEnabled && isOwnedOrLocal && isLatestVersion && (!isUploaded || !isUpToDate);
|
const canUpload = uploadEnabled && isOwnedOrLocal && isLatestVersion && (!isUploaded || !isUpToDate);
|
||||||
const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion;
|
const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion;
|
||||||
const canManageShare = sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId);
|
const canManageShare = sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId);
|
||||||
const canCopyShareLink =
|
const canCopyShareLink = shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId);
|
||||||
shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId);
|
|
||||||
const canDownloadFile = Boolean(onDownload) && hasReadAccess;
|
const canDownloadFile = Boolean(onDownload) && hasReadAccess;
|
||||||
|
|
||||||
const shareBaseUrl = useMemo(() => {
|
const shareBaseUrl = useMemo(() => {
|
||||||
const frontendUrl = (config?.frontendUrl || '').trim();
|
const frontendUrl = (config?.frontendUrl || "").trim();
|
||||||
if (frontendUrl) {
|
if (frontendUrl) {
|
||||||
const normalized = frontendUrl.endsWith('/')
|
const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl;
|
||||||
? frontendUrl.slice(0, -1)
|
|
||||||
: frontendUrl;
|
|
||||||
return `${normalized}/share/`;
|
return `${normalized}/share/`;
|
||||||
}
|
}
|
||||||
return absoluteWithBasePath('/share/');
|
return absoluteWithBasePath("/share/");
|
||||||
}, [config?.frontendUrl]);
|
}, [config?.frontendUrl]);
|
||||||
|
|
||||||
const handleCopyShareLink = useCallback(async () => {
|
const handleCopyShareLink = useCallback(async () => {
|
||||||
@@ -116,14 +112,14 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>(
|
const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>(
|
||||||
`/api/v1/storage/files/${file.remoteStorageId}`,
|
`/api/v1/storage/files/${file.remoteStorageId}`,
|
||||||
{ suppressErrorToast: true } as any
|
{ suppressErrorToast: true } as any,
|
||||||
);
|
);
|
||||||
const links = response.data?.shareLinks ?? [];
|
const links = response.data?.shareLinks ?? [];
|
||||||
const token = links[links.length - 1]?.token;
|
const token = links[links.length - 1]?.token;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
alert({
|
alert({
|
||||||
alertType: 'warning',
|
alertType: "warning",
|
||||||
title: t('storageShare.noLinks', 'No active share links yet.'),
|
title: t("storageShare.noLinks", "No active share links yet."),
|
||||||
expandable: false,
|
expandable: false,
|
||||||
durationMs: 2500,
|
durationMs: 2500,
|
||||||
});
|
});
|
||||||
@@ -131,16 +127,16 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
}
|
}
|
||||||
await navigator.clipboard.writeText(`${shareBaseUrl}${token}`);
|
await navigator.clipboard.writeText(`${shareBaseUrl}${token}`);
|
||||||
alert({
|
alert({
|
||||||
alertType: 'success',
|
alertType: "success",
|
||||||
title: t('storageShare.copied', 'Link copied to clipboard'),
|
title: t("storageShare.copied", "Link copied to clipboard"),
|
||||||
expandable: false,
|
expandable: false,
|
||||||
durationMs: 2000,
|
durationMs: 2000,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to copy share link:', error);
|
console.error("Failed to copy share link:", error);
|
||||||
alert({
|
alert({
|
||||||
alertType: 'warning',
|
alertType: "warning",
|
||||||
title: t('storageShare.copyFailed', 'Copy failed'),
|
title: t("storageShare.copyFailed", "Copy failed"),
|
||||||
expandable: false,
|
expandable: false,
|
||||||
durationMs: 2500,
|
durationMs: 2500,
|
||||||
});
|
});
|
||||||
@@ -152,20 +148,22 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
<Box
|
<Box
|
||||||
p="sm"
|
p="sm"
|
||||||
style={{
|
style={{
|
||||||
cursor: isHistoryFile || isActive ? 'default' : 'pointer',
|
cursor: isHistoryFile || isActive ? "default" : "pointer",
|
||||||
backgroundColor: isActive
|
backgroundColor: isActive
|
||||||
? 'var(--file-active-bg)'
|
? "var(--file-active-bg)"
|
||||||
: isSelected
|
: isSelected
|
||||||
? 'var(--mantine-color-gray-1)'
|
? "var(--mantine-color-gray-1)"
|
||||||
: (shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'var(--bg-file-list)'),
|
: shouldShowHovered
|
||||||
|
? "var(--mantine-color-gray-1)"
|
||||||
|
: "var(--bg-file-list)",
|
||||||
opacity: isSupported ? 1 : 0.5,
|
opacity: isSupported ? 1 : 0.5,
|
||||||
transition: 'background-color 0.15s ease',
|
transition: "background-color 0.15s ease",
|
||||||
userSelect: 'none',
|
userSelect: "none",
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: "none",
|
||||||
MozUserSelect: 'none',
|
MozUserSelect: "none",
|
||||||
msUserSelect: 'none',
|
msUserSelect: "none",
|
||||||
paddingLeft: isHistoryFile ? '2rem' : '0.75rem', // Indent history files
|
paddingLeft: isHistoryFile ? "2rem" : "0.75rem", // Indent history files
|
||||||
borderLeft: isHistoryFile ? '3px solid var(--mantine-color-blue-4)' : 'none' // Visual indicator for history
|
borderLeft: isHistoryFile ? "3px solid var(--mantine-color-blue-4)" : "none", // Visual indicator for history
|
||||||
}}
|
}}
|
||||||
onClick={isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)}
|
onClick={isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
@@ -186,8 +184,8 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
color={isActive ? "green" : undefined}
|
color={isActive ? "green" : undefined}
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
cursor: isActive ? 'not-allowed' : 'pointer'
|
cursor: isActive ? "not-allowed" : "pointer",
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -203,12 +201,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--file-active-badge-bg)',
|
backgroundColor: "var(--file-active-badge-bg)",
|
||||||
color: 'var(--file-active-badge-fg)',
|
color: "var(--file-active-badge-fg)",
|
||||||
border: '1px solid var(--file-active-badge-border)'
|
border: "1px solid var(--file-active-badge-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('fileManager.active', 'Active')}
|
{t("fileManager.active", "Active")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge size="xs" variant="light" color={"blue"}>
|
<Badge size="xs" variant="light" color={"blue"}>
|
||||||
@@ -216,44 +214,33 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
{sharingEnabled && isSharedWithYou ? (
|
{sharingEnabled && isSharedWithYou ? (
|
||||||
<Badge size="xs" variant="light" color="grape">
|
<Badge size="xs" variant="light" color="grape">
|
||||||
{t('fileManager.sharedWithYou', 'Shared with you')}
|
{t("fileManager.sharedWithYou", "Shared with you")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{sharingEnabled && isSharedWithYou && accessRole && accessRole !== 'editor' ? (
|
{sharingEnabled && isSharedWithYou && accessRole && accessRole !== "editor" ? (
|
||||||
<Badge size="xs" variant="light" color="gray">
|
<Badge size="xs" variant="light" color="gray">
|
||||||
{accessRole === 'commenter'
|
{accessRole === "commenter"
|
||||||
? t('storageShare.roleCommenter', 'Commenter')
|
? t("storageShare.roleCommenter", "Commenter")
|
||||||
: t('storageShare.roleViewer', 'Viewer')}
|
: t("storageShare.roleViewer", "Viewer")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : isLocalOnly ? (
|
) : isLocalOnly ? (
|
||||||
<Badge size="xs" variant="light" color="gray">
|
<Badge size="xs" variant="light" color="gray">
|
||||||
{t('fileManager.localOnly', 'Local only')}
|
{t("fileManager.localOnly", "Local only")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : uploadEnabled && isOutOfSync ? (
|
) : uploadEnabled && isOutOfSync ? (
|
||||||
<Badge
|
<Badge size="xs" variant="light" color="yellow" leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}>
|
||||||
size="xs"
|
{t("fileManager.changesNotUploaded", "Changes not uploaded")}
|
||||||
variant="light"
|
|
||||||
color="yellow"
|
|
||||||
leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}
|
|
||||||
>
|
|
||||||
{t('fileManager.changesNotUploaded', 'Changes not uploaded')}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
) : uploadEnabled && isUploaded ? (
|
) : uploadEnabled && isUploaded ? (
|
||||||
<Badge
|
<Badge size="xs" variant="light" color="teal" leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}>
|
||||||
size="xs"
|
{t("fileManager.synced", "Synced")}
|
||||||
variant="light"
|
|
||||||
color="teal"
|
|
||||||
leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}
|
|
||||||
>
|
|
||||||
{t('fileManager.synced', 'Synced')}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && (
|
{sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && (
|
||||||
<Badge size="xs" variant="light" color="blue">
|
<Badge size="xs" variant="light" color="blue">
|
||||||
{t('fileManager.sharedByYou', 'Shared by you')}
|
{t("fileManager.sharedByYou", "Shared by you")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs" align="center">
|
<Group gap="xs" align="center">
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
@@ -262,12 +249,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
|
|
||||||
{/* Tool chain for processed files */}
|
{/* Tool chain for processed files */}
|
||||||
{file.toolHistory && file.toolHistory.length > 0 && (
|
{file.toolHistory && file.toolHistory.length > 0 && (
|
||||||
<ToolChain
|
<ToolChain toolChain={file.toolHistory} maxWidth={"150px"} displayStyle="text" size="xs" />
|
||||||
toolChain={file.toolHistory}
|
|
||||||
maxWidth={'150px'}
|
|
||||||
displayStyle="text"
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -288,9 +270,9 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
opacity: shouldShowHovered ? 1 : 0,
|
opacity: shouldShowHovered ? 1 : 0,
|
||||||
transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
|
transform: shouldShowHovered ? "scale(1)" : "scale(0.8)",
|
||||||
transition: 'opacity 0.3s ease, transform 0.3s ease',
|
transition: "opacity 0.3s ease, transform 0.3s ease",
|
||||||
pointerEvents: shouldShowHovered ? 'auto' : 'none'
|
pointerEvents: shouldShowHovered ? "auto" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MoreVertIcon style={{ fontSize: 20 }} />
|
<MoreVertIcon style={{ fontSize: 20 }} />
|
||||||
@@ -308,7 +290,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
removeFiles([file.id]);
|
removeFiles([file.id]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('fileManager.closeFile', 'Close File')}
|
{t("fileManager.closeFile", "Close File")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
</>
|
</>
|
||||||
@@ -322,7 +304,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
onDownload?.();
|
onDownload?.();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('fileManager.download', 'Download')}
|
{t("fileManager.download", "Download")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -335,8 +317,8 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isUploaded
|
{isUploaded
|
||||||
? t('fileManager.updateOnServer', 'Update on Server')
|
? t("fileManager.updateOnServer", "Update on Server")
|
||||||
: t('fileManager.uploadToServer', 'Upload to Server')}
|
: t("fileManager.uploadToServer", "Upload to Server")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -348,7 +330,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
setShowShareModal(true);
|
setShowShareModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('fileManager.share', 'Share')}
|
{t("fileManager.share", "Share")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -360,7 +342,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
void handleCopyShareLink();
|
void handleCopyShareLink();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('storageShare.copyLink', 'Copy share link')}
|
{t("storageShare.copyLink", "Copy share link")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -372,7 +354,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
setShowShareManageModal(true);
|
setShowShareManageModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('storageShare.manage', 'Manage sharing')}
|
{t("storageShare.manage", "Manage sharing")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -380,20 +362,13 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
{isLatestVersion && hasVersionHistory && (
|
{isLatestVersion && hasVersionHistory && (
|
||||||
<>
|
<>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={
|
leftSection={<HistoryIcon style={{ fontSize: 16 }} />}
|
||||||
<HistoryIcon style={{ fontSize: 16 }} />
|
|
||||||
}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggleExpansion(leafFileId);
|
onToggleExpansion(leafFileId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{
|
{isExpanded ? t("fileManager.hideHistory", "Hide History") : t("fileManager.showHistory", "Show History")}
|
||||||
(isExpanded ?
|
|
||||||
t('fileManager.hideHistory', 'Hide History') :
|
|
||||||
t('fileManager.showHistory', 'Show History')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
</>
|
</>
|
||||||
@@ -408,7 +383,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('fileManager.restore', 'Restore')}
|
{t("fileManager.restore", "Restore")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
</>
|
</>
|
||||||
@@ -424,7 +399,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
onUnzipFile(file);
|
onUnzipFile(file);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('fileManager.unzip', 'Unzip')}
|
{t("fileManager.unzip", "Unzip")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
</>
|
</>
|
||||||
@@ -437,14 +412,13 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
onRemove();
|
onRemove();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('fileManager.delete', 'Delete')}
|
{t("fileManager.delete", "Delete")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
{ <Divider color="var(--mantine-color-gray-3)" />}
|
{<Divider color="var(--mantine-color-gray-3)" />}
|
||||||
{canUpload && (
|
{canUpload && (
|
||||||
<UploadToServerModal
|
<UploadToServerModal
|
||||||
opened={showUploadModal}
|
opened={showUploadModal}
|
||||||
@@ -462,11 +436,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canManageShare && (
|
{canManageShare && (
|
||||||
<ShareManagementModal
|
<ShareManagementModal opened={showShareManageModal} onClose={() => setShowShareManageModal(false)} file={file} />
|
||||||
opened={showShareManageModal}
|
|
||||||
onClose={() => setShowShareManageModal(false)}
|
|
||||||
file={file}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { Stack, Text, Button, Group } from '@mantine/core';
|
import { Stack, Text, Button, Group } from "@mantine/core";
|
||||||
import HistoryIcon from '@mui/icons-material/History';
|
import HistoryIcon from "@mui/icons-material/History";
|
||||||
import PhonelinkIcon from '@mui/icons-material/Phonelink';
|
import PhonelinkIcon from "@mui/icons-material/Phonelink";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||||
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
|
import { useGoogleDrivePicker } from "@app/hooks/useGoogleDrivePicker";
|
||||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
|
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||||
|
|
||||||
interface FileSourceButtonsProps {
|
interface FileSourceButtonsProps {
|
||||||
horizontal?: boolean;
|
horizontal?: boolean;
|
||||||
@@ -24,17 +24,15 @@ const GoogleDriveIcon: React.FC<{ disabled?: boolean }> = ({ disabled }) => (
|
|||||||
src="/images/google-drive.svg"
|
src="/images/google-drive.svg"
|
||||||
alt="Google Drive"
|
alt="Google Drive"
|
||||||
style={{
|
style={{
|
||||||
width: '20px',
|
width: "20px",
|
||||||
height: '20px',
|
height: "20px",
|
||||||
opacity: disabled ? 0.5 : 1,
|
opacity: disabled ? 0.5 : 1,
|
||||||
filter: disabled ? 'grayscale(100%)' : 'none',
|
filter: disabled ? "grayscale(100%)" : "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = false }) => {
|
||||||
horizontal = false
|
|
||||||
}) => {
|
|
||||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext();
|
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
||||||
@@ -53,7 +51,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
onGoogleDriveSelect(files);
|
onGoogleDriveSelect(files);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to pick files from Google Drive:', error);
|
console.error("Failed to pick files from Google Drive:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,18 +72,18 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
const shouldHideMobileQR = !isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner;
|
const shouldHideMobileQR = !isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner;
|
||||||
|
|
||||||
const buttonProps = {
|
const buttonProps = {
|
||||||
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
|
variant: (source: string) => (activeSource === source ? "filled" : "subtle"),
|
||||||
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
|
getColor: (source: string) => (activeSource === source ? "var(--mantine-color-gray-2)" : undefined),
|
||||||
getStyles: (source: string) => ({
|
getStyles: (source: string) => ({
|
||||||
root: {
|
root: {
|
||||||
backgroundColor: activeSource === source ? undefined : 'transparent',
|
backgroundColor: activeSource === source ? undefined : "transparent",
|
||||||
color: activeSource === source ? 'var(--mantine-color-gray-9)' : 'var(--mantine-color-gray-6)',
|
color: activeSource === source ? "var(--mantine-color-gray-9)" : "var(--mantine-color-gray-6)",
|
||||||
border: 'none',
|
border: "none",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
backgroundColor: activeSource === source ? undefined : 'var(--mantine-color-gray-0)'
|
backgroundColor: activeSource === source ? undefined : "var(--mantine-color-gray-0)",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const buttons = (
|
const buttons = (
|
||||||
@@ -93,18 +91,18 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
leftSection={<HistoryIcon />}
|
leftSection={<HistoryIcon />}
|
||||||
justify={horizontal ? "center" : "flex-start"}
|
justify={horizontal ? "center" : "flex-start"}
|
||||||
onClick={() => onSourceChange('recent')}
|
onClick={() => onSourceChange("recent")}
|
||||||
fullWidth={!horizontal}
|
fullWidth={!horizontal}
|
||||||
size={horizontal ? "xs" : "sm"}
|
size={horizontal ? "xs" : "sm"}
|
||||||
color={buttonProps.getColor('recent')}
|
color={buttonProps.getColor("recent")}
|
||||||
styles={buttonProps.getStyles('recent')}
|
styles={buttonProps.getStyles("recent")}
|
||||||
>
|
>
|
||||||
{horizontal ? t('fileManager.recent', 'Recent') : t('fileManager.recent', 'Recent')}
|
{horizontal ? t("fileManager.recent", "Recent") : t("fileManager.recent", "Recent")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color='var(--mantine-color-gray-6)'
|
color="var(--mantine-color-gray-6)"
|
||||||
leftSection={<UploadIcon />}
|
leftSection={<UploadIcon />}
|
||||||
justify={horizontal ? "center" : "flex-start"}
|
justify={horizontal ? "center" : "flex-start"}
|
||||||
onClick={onLocalFileClick}
|
onClick={onLocalFileClick}
|
||||||
@@ -112,12 +110,12 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
size={horizontal ? "xs" : "sm"}
|
size={horizontal ? "xs" : "sm"}
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: "transparent",
|
||||||
border: 'none',
|
border: "none",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
backgroundColor: 'var(--mantine-color-gray-0)'
|
backgroundColor: "var(--mantine-color-gray-0)",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{horizontal ? terminology.upload : terminology.uploadFiles}
|
{horizontal ? terminology.upload : terminology.uploadFiles}
|
||||||
@@ -126,7 +124,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
{!shouldHideGoogleDrive && (
|
{!shouldHideGoogleDrive && (
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color='var(--mantine-color-gray-6)'
|
color="var(--mantine-color-gray-6)"
|
||||||
leftSection={<GoogleDriveIcon disabled={!isGoogleDriveEnabled} />}
|
leftSection={<GoogleDriveIcon disabled={!isGoogleDriveEnabled} />}
|
||||||
justify={horizontal ? "center" : "flex-start"}
|
justify={horizontal ? "center" : "flex-start"}
|
||||||
onClick={handleGoogleDriveClick}
|
onClick={handleGoogleDriveClick}
|
||||||
@@ -135,23 +133,27 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
disabled={!isGoogleDriveEnabled}
|
disabled={!isGoogleDriveEnabled}
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: "transparent",
|
||||||
border: 'none',
|
border: "none",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
backgroundColor: isGoogleDriveEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
|
backgroundColor: isGoogleDriveEnabled ? "var(--mantine-color-gray-0)" : "transparent",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
title={!isGoogleDriveEnabled ? t('fileManager.googleDriveNotAvailable', 'Google Drive integration not available') : undefined}
|
title={
|
||||||
|
!isGoogleDriveEnabled
|
||||||
|
? t("fileManager.googleDriveNotAvailable", "Google Drive integration not available")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
{horizontal ? t("fileManager.googleDriveShort", "Drive") : t("fileManager.googleDrive", "Google Drive")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!shouldHideMobileQR && (
|
{!shouldHideMobileQR && (
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color='var(--mantine-color-gray-6)'
|
color="var(--mantine-color-gray-6)"
|
||||||
leftSection={<PhonelinkIcon />}
|
leftSection={<PhonelinkIcon />}
|
||||||
justify={horizontal ? "center" : "flex-start"}
|
justify={horizontal ? "center" : "flex-start"}
|
||||||
onClick={handleMobileUploadClick}
|
onClick={handleMobileUploadClick}
|
||||||
@@ -160,16 +162,16 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
disabled={!isMobileUploadEnabled}
|
disabled={!isMobileUploadEnabled}
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: "transparent",
|
||||||
border: 'none',
|
border: "none",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
backgroundColor: isMobileUploadEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
|
backgroundColor: isMobileUploadEnabled ? "var(--mantine-color-gray-0)" : "transparent",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
title={!isMobileUploadEnabled ? t('fileManager.mobileUploadNotAvailable', 'Mobile upload not available') : undefined}
|
title={!isMobileUploadEnabled ? t("fileManager.mobileUploadNotAvailable", "Mobile upload not available") : undefined}
|
||||||
>
|
>
|
||||||
{horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')}
|
{horizontal ? t("fileManager.mobileShort", "Mobile") : t("fileManager.mobileUpload", "Mobile Upload")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -178,7 +180,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
if (horizontal) {
|
if (horizontal) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group gap="xs" justify="center" style={{ width: '100%' }}>
|
<Group gap="xs" justify="center" style={{ width: "100%" }}>
|
||||||
{buttons}
|
{buttons}
|
||||||
</Group>
|
</Group>
|
||||||
<MobileUploadModal
|
<MobileUploadModal
|
||||||
@@ -192,9 +194,9 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack gap="xs" style={{ height: '100%' }}>
|
<Stack gap="xs" style={{ height: "100%" }}>
|
||||||
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
|
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: "1rem" }}>
|
||||||
{t('fileManager.myFiles', 'My Files')}
|
{t("fileManager.myFiles", "My Files")}
|
||||||
</Text>
|
</Text>
|
||||||
{buttons}
|
{buttons}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||||
|
|
||||||
const HiddenFileInput: React.FC = () => {
|
const HiddenFileInput: React.FC = () => {
|
||||||
const { fileInputRef, onFileInputChange } = useFileManagerContext();
|
const { fileInputRef, onFileInputChange } = useFileManagerContext();
|
||||||
@@ -10,7 +10,7 @@ const HiddenFileInput: React.FC = () => {
|
|||||||
type="file"
|
type="file"
|
||||||
multiple={true}
|
multiple={true}
|
||||||
onChange={onFileInputChange}
|
onChange={onFileInputChange}
|
||||||
style={{ display: 'none' }}
|
style={{ display: "none" }}
|
||||||
data-testid="file-input"
|
data-testid="file-input"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from "@mantine/core";
|
||||||
import FileSourceButtons from '@app/components/fileManager/FileSourceButtons';
|
import FileSourceButtons from "@app/components/fileManager/FileSourceButtons";
|
||||||
import FileDetails from '@app/components/fileManager/FileDetails';
|
import FileDetails from "@app/components/fileManager/FileDetails";
|
||||||
import SearchInput from '@app/components/fileManager/SearchInput';
|
import SearchInput from "@app/components/fileManager/SearchInput";
|
||||||
import FileListArea from '@app/components/fileManager/FileListArea';
|
import FileListArea from "@app/components/fileManager/FileListArea";
|
||||||
import FileActions from '@app/components/fileManager/FileActions';
|
import FileActions from "@app/components/fileManager/FileActions";
|
||||||
import HiddenFileInput from '@app/components/fileManager/HiddenFileInput';
|
import HiddenFileInput from "@app/components/fileManager/HiddenFileInput";
|
||||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||||
|
|
||||||
const MobileLayout: React.FC = () => {
|
const MobileLayout: React.FC = () => {
|
||||||
const {
|
const { activeSource, selectedFiles, modalHeight } = useFileManagerContext();
|
||||||
activeSource,
|
|
||||||
selectedFiles,
|
|
||||||
modalHeight,
|
|
||||||
} = useFileManagerContext();
|
|
||||||
|
|
||||||
// Calculate the height more accurately based on actual content
|
// Calculate the height more accurately based on actual content
|
||||||
const calculateFileListHeight = () => {
|
const calculateFileListHeight = () => {
|
||||||
@@ -21,17 +17,17 @@ const MobileLayout: React.FC = () => {
|
|||||||
const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding
|
const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding
|
||||||
|
|
||||||
// Estimate heights of fixed components
|
// Estimate heights of fixed components
|
||||||
const fileSourceHeight = '3rem'; // FileSourceButtons height
|
const fileSourceHeight = "3rem"; // FileSourceButtons height
|
||||||
const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height
|
const fileDetailsHeight = selectedFiles.length > 0 ? "10rem" : "8rem"; // FileDetails compact height
|
||||||
const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom)
|
const fileActionsHeight = activeSource === "recent" ? "3rem" : "0rem"; // FileActions height (now at bottom)
|
||||||
const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height
|
const searchHeight = activeSource === "recent" ? "3rem" : "0rem"; // SearchInput height
|
||||||
const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps
|
const gapHeight = activeSource === "recent" ? "3.75rem" : "2rem"; // Stack gaps
|
||||||
|
|
||||||
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`;
|
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" p="sm" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<Box h="100%" p="sm" style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
{/* Section 1: File Sources - Fixed at top */}
|
{/* Section 1: File Sources - Fixed at top */}
|
||||||
<Box style={{ flexShrink: 0 }}>
|
<Box style={{ flexShrink: 0 }}>
|
||||||
<FileSourceButtons horizontal={true} />
|
<FileSourceButtons horizontal={true} />
|
||||||
@@ -42,28 +38,34 @@ const MobileLayout: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
|
{/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
|
||||||
<Box style={{
|
<Box
|
||||||
flex: 1,
|
style={{
|
||||||
display: 'flex',
|
flex: 1,
|
||||||
flexDirection: 'column',
|
display: "flex",
|
||||||
backgroundColor: 'var(--bg-file-list)',
|
flexDirection: "column",
|
||||||
borderRadius: '0.5rem',
|
backgroundColor: "var(--bg-file-list)",
|
||||||
border: '1px solid var(--mantine-color-gray-2)',
|
borderRadius: "0.5rem",
|
||||||
overflow: 'hidden',
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
minHeight: 0
|
overflow: "hidden",
|
||||||
}}>
|
minHeight: 0,
|
||||||
{activeSource === 'recent' && (
|
}}
|
||||||
|
>
|
||||||
|
{activeSource === "recent" && (
|
||||||
<>
|
<>
|
||||||
<Box style={{
|
<Box
|
||||||
flexShrink: 0,
|
style={{
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
flexShrink: 0,
|
||||||
}}>
|
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
</Box>
|
</Box>
|
||||||
<Box style={{
|
<Box
|
||||||
flexShrink: 0,
|
style={{
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-2)'
|
flexShrink: 0,
|
||||||
}}>
|
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<FileActions />
|
<FileActions />
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
@@ -74,11 +76,11 @@ const MobileLayout: React.FC = () => {
|
|||||||
scrollAreaHeight={calculateFileListHeight()}
|
scrollAreaHeight={calculateFileListHeight()}
|
||||||
scrollAreaStyle={{
|
scrollAreaStyle={{
|
||||||
height: calculateFileListHeight(),
|
height: calculateFileListHeight(),
|
||||||
maxHeight: '60vh',
|
maxHeight: "60vh",
|
||||||
minHeight: '9.375rem',
|
minHeight: "9.375rem",
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: "transparent",
|
||||||
border: 'none',
|
border: "none",
|
||||||
borderRadius: 0
|
borderRadius: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { TextInput } from '@mantine/core';
|
import { TextInput } from "@mantine/core";
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||||
|
|
||||||
interface SearchInputProps {
|
interface SearchInputProps {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
@@ -14,17 +14,16 @@ const SearchInput: React.FC<SearchInputProps> = ({ style }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder={t('fileManager.searchFiles', 'Search files...')}
|
placeholder={t("fileManager.searchFiles", "Search files...")}
|
||||||
leftSection={<SearchIcon />}
|
leftSection={<SearchIcon />}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
style={{ padding: "0.5rem", ...style }}
|
||||||
style={{ padding: '0.5rem', ...style }}
|
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
border: 'none',
|
border: "none",
|
||||||
backgroundColor: 'transparent'
|
backgroundColor: "transparent",
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { HotkeyBinding } from '@app/utils/hotkeys';
|
import { HotkeyBinding } from "@app/utils/hotkeys";
|
||||||
import { useHotkeys } from '@app/contexts/HotkeyContext';
|
import { useHotkeys } from "@app/contexts/HotkeyContext";
|
||||||
|
|
||||||
interface HotkeyDisplayProps {
|
interface HotkeyDisplayProps {
|
||||||
binding: HotkeyBinding | null | undefined;
|
binding: HotkeyBinding | null | undefined;
|
||||||
size?: 'sm' | 'md';
|
size?: "sm" | "md";
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseKeyStyle: React.CSSProperties = {
|
const baseKeyStyle: React.CSSProperties = {
|
||||||
display: 'inline-flex',
|
display: "inline-flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
borderRadius: '0.375rem',
|
borderRadius: "0.375rem",
|
||||||
background: 'var(--mantine-color-gray-1)',
|
background: "var(--mantine-color-gray-1)",
|
||||||
border: '1px solid var(--mantine-color-gray-3)',
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
padding: '0.125rem 0.35rem',
|
padding: "0.125rem 0.35rem",
|
||||||
fontSize: '0.75rem',
|
fontSize: "0.75rem",
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
fontFamily: 'var(--mantine-font-family-monospace, monospace)',
|
fontFamily: "var(--mantine-font-family-monospace, monospace)",
|
||||||
minWidth: '1.35rem',
|
minWidth: "1.35rem",
|
||||||
color: 'var(--mantine-color-text)',
|
color: "var(--mantine-color-text)",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 'sm', muted = false }) => {
|
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = "sm", muted = false }) => {
|
||||||
const { getDisplayParts } = useHotkeys();
|
const { getDisplayParts } = useHotkeys();
|
||||||
const parts = getDisplayParts(binding);
|
const parts = getDisplayParts(binding);
|
||||||
|
|
||||||
@@ -31,24 +31,26 @@ export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 's
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyStyle = size === 'md'
|
const keyStyle = size === "md" ? { ...baseKeyStyle, fontSize: "0.85rem", padding: "0.2rem 0.5rem" } : baseKeyStyle;
|
||||||
? { ...baseKeyStyle, fontSize: '0.85rem', padding: '0.2rem 0.5rem' }
|
|
||||||
: baseKeyStyle;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: "inline-flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: '0.25rem',
|
gap: "0.25rem",
|
||||||
color: muted ? 'var(--mantine-color-dimmed)' : 'inherit',
|
color: muted ? "var(--mantine-color-dimmed)" : "inherit",
|
||||||
fontWeight: muted ? 500 : 600,
|
fontWeight: muted ? 500 : 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{parts.map((part, index) => (
|
{parts.map((part, index) => (
|
||||||
<React.Fragment key={`${part}-${index}`}>
|
<React.Fragment key={`${part}-${index}`}>
|
||||||
<kbd style={keyStyle}>{part}</kbd>
|
<kbd style={keyStyle}>{part}</kbd>
|
||||||
{index < parts.length - 1 && <span aria-hidden style={{ fontWeight: 400 }}>+</span>}
|
{index < parts.length - 1 && (
|
||||||
|
<span aria-hidden style={{ fontWeight: 400 }}>
|
||||||
|
+
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from "react";
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from "@mantine/core";
|
||||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||||
import { useFileState, useFileActions } from '@app/contexts/FileContext';
|
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||||
import { useNavigationState, useNavigationActions, useNavigationGuard } from '@app/contexts/NavigationContext';
|
import { useNavigationState, useNavigationActions, useNavigationGuard } from "@app/contexts/NavigationContext";
|
||||||
import { isBaseWorkbench } from '@app/types/workbench';
|
import { isBaseWorkbench } from "@app/types/workbench";
|
||||||
import { useViewer } from '@app/contexts/ViewerContext';
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import { FileId } from '@app/types/file';
|
import { FileId } from "@app/types/file";
|
||||||
import styles from '@app/components/layout/Workbench.module.css';
|
import styles from "@app/components/layout/Workbench.module.css";
|
||||||
|
|
||||||
import TopControls from '@app/components/shared/TopControls';
|
import TopControls from "@app/components/shared/TopControls";
|
||||||
import FileEditor from '@app/components/fileEditor/FileEditor';
|
import FileEditor from "@app/components/fileEditor/FileEditor";
|
||||||
import PageEditor from '@app/components/pageEditor/PageEditor';
|
import PageEditor from "@app/components/pageEditor/PageEditor";
|
||||||
import PageEditorControls from '@app/components/pageEditor/PageEditorControls';
|
import PageEditorControls from "@app/components/pageEditor/PageEditorControls";
|
||||||
import Viewer from '@app/components/viewer/Viewer';
|
import Viewer from "@app/components/viewer/Viewer";
|
||||||
import LandingPage from '@app/components/shared/LandingPage';
|
import LandingPage from "@app/components/shared/LandingPage";
|
||||||
import Footer from '@app/components/shared/Footer';
|
import Footer from "@app/components/shared/Footer";
|
||||||
import DismissAllErrorsButton from '@app/components/shared/DismissAllErrorsButton';
|
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
|
||||||
|
|
||||||
// No props needed - component uses contexts directly
|
// No props needed - component uses contexts directly
|
||||||
export default function Workbench() {
|
export default function Workbench() {
|
||||||
@@ -60,35 +60,41 @@ export default function Workbench() {
|
|||||||
|
|
||||||
// Wrap file selection to check for unsaved changes before switching
|
// Wrap file selection to check for unsaved changes before switching
|
||||||
// requestNavigation will show the modal if there are unsaved changes, otherwise navigate immediately
|
// requestNavigation will show the modal if there are unsaved changes, otherwise navigate immediately
|
||||||
const handleFileSelect = useCallback((index: number) => {
|
const handleFileSelect = useCallback(
|
||||||
// Don't do anything if selecting the same file
|
(index: number) => {
|
||||||
if (index === activeFileIndex) return;
|
// Don't do anything if selecting the same file
|
||||||
|
if (index === activeFileIndex) return;
|
||||||
|
|
||||||
// requestNavigation handles the unsaved changes check internally
|
// requestNavigation handles the unsaved changes check internally
|
||||||
requestNavigation(() => {
|
requestNavigation(() => {
|
||||||
setActiveFileIndex(index);
|
setActiveFileIndex(index);
|
||||||
});
|
});
|
||||||
}, [activeFileIndex, requestNavigation, setActiveFileIndex]);
|
},
|
||||||
|
[activeFileIndex, requestNavigation, setActiveFileIndex],
|
||||||
|
);
|
||||||
|
|
||||||
const handleFileRemove = useCallback(async (fileId: FileId) => {
|
const handleFileRemove = useCallback(
|
||||||
await fileActions.removeFiles([fileId], false); // false = don't delete from IndexedDB, just remove from context
|
async (fileId: FileId) => {
|
||||||
}, [fileActions]);
|
await fileActions.removeFiles([fileId], false); // false = don't delete from IndexedDB, just remove from context
|
||||||
|
},
|
||||||
|
[fileActions],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePreviewClose = () => {
|
const handlePreviewClose = () => {
|
||||||
setPreviewFile(null);
|
setPreviewFile(null);
|
||||||
const previousMode = sessionStorage.getItem('previousMode');
|
const previousMode = sessionStorage.getItem("previousMode");
|
||||||
if (previousMode === 'split') {
|
if (previousMode === "split") {
|
||||||
// Use context's handleToolSelect which coordinates tool selection and view changes
|
// Use context's handleToolSelect which coordinates tool selection and view changes
|
||||||
handleToolSelect('split');
|
handleToolSelect("split");
|
||||||
sessionStorage.removeItem('previousMode');
|
sessionStorage.removeItem("previousMode");
|
||||||
} else if (previousMode === 'compress') {
|
} else if (previousMode === "compress") {
|
||||||
handleToolSelect('compress');
|
handleToolSelect("compress");
|
||||||
sessionStorage.removeItem('previousMode');
|
sessionStorage.removeItem("previousMode");
|
||||||
} else if (previousMode === 'convert') {
|
} else if (previousMode === "convert") {
|
||||||
handleToolSelect('convert');
|
handleToolSelect("convert");
|
||||||
sessionStorage.removeItem('previousMode');
|
sessionStorage.removeItem("previousMode");
|
||||||
} else {
|
} else {
|
||||||
setCurrentView('fileEditor');
|
setCurrentView("fileEditor");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,15 +110,11 @@ export default function Workbench() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeFiles.length === 0) {
|
if (activeFiles.length === 0) {
|
||||||
return (
|
return <LandingPage />;
|
||||||
<LandingPage
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (currentView) {
|
switch (currentView) {
|
||||||
case "fileEditor":
|
case "fileEditor":
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileEditor
|
<FileEditor
|
||||||
toolMode={!!selectedToolId}
|
toolMode={!!selectedToolId}
|
||||||
@@ -124,13 +126,12 @@ export default function Workbench() {
|
|||||||
onMergeFiles: (filesToMerge) => {
|
onMergeFiles: (filesToMerge) => {
|
||||||
addFiles(filesToMerge);
|
addFiles(filesToMerge);
|
||||||
setCurrentView("viewer");
|
setCurrentView("viewer");
|
||||||
}
|
},
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "viewer":
|
case "viewer":
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Viewer
|
<Viewer
|
||||||
sidebarsVisible={sidebarsVisible}
|
sidebarsVisible={sidebarsVisible}
|
||||||
@@ -143,34 +144,31 @@ export default function Workbench() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case "pageEditor":
|
case "pageEditor":
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', flex: '1 1 0', height: 0 }}>
|
<div style={{ position: "relative", flex: "1 1 0", height: 0 }}>
|
||||||
<PageEditor
|
<PageEditor onFunctionsReady={setPageEditorFunctions} />
|
||||||
onFunctionsReady={setPageEditorFunctions}
|
|
||||||
/>
|
|
||||||
{pageEditorFunctions && (
|
{pageEditorFunctions && (
|
||||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 100 }}>
|
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, zIndex: 100 }}>
|
||||||
<PageEditorControls
|
<PageEditorControls
|
||||||
onClosePdf={pageEditorFunctions.closePdf}
|
onClosePdf={pageEditorFunctions.closePdf}
|
||||||
onUndo={pageEditorFunctions.handleUndo}
|
onUndo={pageEditorFunctions.handleUndo}
|
||||||
onRedo={pageEditorFunctions.handleRedo}
|
onRedo={pageEditorFunctions.handleRedo}
|
||||||
canUndo={pageEditorFunctions.canUndo}
|
canUndo={pageEditorFunctions.canUndo}
|
||||||
canRedo={pageEditorFunctions.canRedo}
|
canRedo={pageEditorFunctions.canRedo}
|
||||||
onRotate={pageEditorFunctions.handleRotate}
|
onRotate={pageEditorFunctions.handleRotate}
|
||||||
onDelete={pageEditorFunctions.handleDelete}
|
onDelete={pageEditorFunctions.handleDelete}
|
||||||
onSplit={pageEditorFunctions.handleSplit}
|
onSplit={pageEditorFunctions.handleSplit}
|
||||||
onSplitAll={pageEditorFunctions.handleSplitAll}
|
onSplitAll={pageEditorFunctions.handleSplitAll}
|
||||||
onPageBreak={pageEditorFunctions.handlePageBreak}
|
onPageBreak={pageEditorFunctions.handlePageBreak}
|
||||||
onPageBreakAll={pageEditorFunctions.handlePageBreakAll}
|
onPageBreakAll={pageEditorFunctions.handlePageBreakAll}
|
||||||
onExportAll={pageEditorFunctions.onExportAll}
|
onExportAll={pageEditorFunctions.onExportAll}
|
||||||
exportLoading={pageEditorFunctions.exportLoading}
|
exportLoading={pageEditorFunctions.exportLoading}
|
||||||
selectionMode={pageEditorFunctions.selectionMode}
|
selectionMode={pageEditorFunctions.selectionMode}
|
||||||
selectedPageIds={pageEditorFunctions.selectedPageIds}
|
selectedPageIds={pageEditorFunctions.selectedPageIds}
|
||||||
displayDocument={pageEditorFunctions.displayDocument}
|
displayDocument={pageEditorFunctions.displayDocument}
|
||||||
splitPositions={pageEditorFunctions.splitPositions}
|
splitPositions={pageEditorFunctions.splitPositions}
|
||||||
totalPages={pageEditorFunctions.totalPages}
|
totalPages={pageEditorFunctions.totalPages}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -188,16 +186,16 @@ export default function Workbench() {
|
|||||||
style={
|
style={
|
||||||
isRainbowMode
|
isRainbowMode
|
||||||
? {} // No background color in rainbow mode
|
? {} // No background color in rainbow mode
|
||||||
: { backgroundColor: 'var(--bg-background)' }
|
: { backgroundColor: "var(--bg-background)" }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Top Controls */}
|
{/* Top Controls */}
|
||||||
{activeFiles.length > 0 && !customWorkbenchViews.find(v => v.workbenchId === currentView)?.hideTopControls && (
|
{activeFiles.length > 0 && !customWorkbenchViews.find((v) => v.workbenchId === currentView)?.hideTopControls && (
|
||||||
<TopControls
|
<TopControls
|
||||||
currentView={currentView}
|
currentView={currentView}
|
||||||
setCurrentView={setCurrentView}
|
setCurrentView={setCurrentView}
|
||||||
customViews={customWorkbenchViews}
|
customViews={customWorkbenchViews}
|
||||||
activeFiles={activeFiles.map(f => {
|
activeFiles={activeFiles.map((f) => {
|
||||||
const stub = selectors.getStirlingFileStub(f.fileId);
|
const stub = selectors.getStirlingFileStub(f.fileId);
|
||||||
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
|
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
|
||||||
})}
|
})}
|
||||||
@@ -212,10 +210,10 @@ export default function Workbench() {
|
|||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<Box
|
<Box
|
||||||
className={`flex-1 min-h-0 z-10 ${currentView === 'pageEditor' ? 'relative flex flex-col' : `relative ${styles.workbenchScrollable}`}`}
|
className={`flex-1 min-h-0 z-10 ${currentView === "pageEditor" ? "relative flex flex-col" : `relative ${styles.workbenchScrollable}`}`}
|
||||||
style={{
|
style={{
|
||||||
transition: 'opacity 0.15s ease-in-out',
|
transition: "opacity 0.15s ease-in-out",
|
||||||
...(currentView === 'pageEditor' && { height: 0 }),
|
...(currentView === "pageEditor" && { height: 0 }),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderMainContent()}
|
{renderMainContent()}
|
||||||
|
|||||||
+30
-8
@@ -118,7 +118,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.heroIconsContainer {
|
.heroIconsContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
@@ -141,7 +140,9 @@
|
|||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
opacity 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -181,7 +182,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iconLabel {
|
.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-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
@@ -266,7 +274,7 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||||
background: 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);
|
box-shadow: 0 0 8px rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +290,14 @@
|
|||||||
|
|
||||||
/* Title styles */
|
/* Title styles */
|
||||||
.titleText {
|
.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-weight: 600;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
color: var(--onboarding-title);
|
color: var(--onboarding-title);
|
||||||
@@ -290,7 +305,14 @@
|
|||||||
|
|
||||||
/* Body text styles */
|
/* Body text styles */
|
||||||
.bodyText {
|
.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;
|
font-size: 16px;
|
||||||
color: var(--onboarding-body);
|
color: var(--onboarding-body);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -314,8 +336,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.v2Badge {
|
.v2Badge {
|
||||||
background: #DBEFFF;
|
background: #dbefff;
|
||||||
color: #2A4BFF;
|
color: #2a4bff;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Button, Group, ActionIcon } from '@mantine/core';
|
import { Button, Group, ActionIcon } from "@mantine/core";
|
||||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onboardingFlowConfig';
|
import { ButtonDefinition, type FlowState } from "@app/components/onboarding/onboardingFlowConfig";
|
||||||
import type { LicenseNotice } from '@app/types/types';
|
import type { LicenseNotice } from "@app/types/types";
|
||||||
import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
import type { ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
|
||||||
|
|
||||||
interface SlideButtonsProps {
|
interface SlideButtonsProps {
|
||||||
slideDefinition: {
|
slideDefinition: {
|
||||||
@@ -18,49 +18,49 @@ interface SlideButtonsProps {
|
|||||||
|
|
||||||
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
|
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
|
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left");
|
||||||
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
|
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right");
|
||||||
|
|
||||||
const buttonStyles = (variant: ButtonDefinition['variant']) =>
|
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
|
||||||
variant === 'primary'
|
variant === "primary"
|
||||||
? {
|
? {
|
||||||
root: {
|
root: {
|
||||||
background: 'var(--onboarding-primary-button-bg)',
|
background: "var(--onboarding-primary-button-bg)",
|
||||||
color: 'var(--onboarding-primary-button-text)',
|
color: "var(--onboarding-primary-button-text)",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
root: {
|
root: {
|
||||||
background: 'var(--onboarding-secondary-button-bg)',
|
background: "var(--onboarding-secondary-button-bg)",
|
||||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
border: "1px solid var(--onboarding-secondary-button-border)",
|
||||||
color: 'var(--onboarding-secondary-button-text)',
|
color: "var(--onboarding-secondary-button-text)",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveButtonLabel = (button: ButtonDefinition) => {
|
const resolveButtonLabel = (button: ButtonDefinition) => {
|
||||||
// Special case: override "See Plans" with "Upgrade now" when over limit
|
// Special case: override "See Plans" with "Upgrade now" when over limit
|
||||||
if (
|
if (
|
||||||
button.type === 'button' &&
|
button.type === "button" &&
|
||||||
slideDefinition.id === 'server-license' &&
|
slideDefinition.id === "server-license" &&
|
||||||
button.action === 'see-plans' &&
|
button.action === "see-plans" &&
|
||||||
licenseNotice.isOverLimit
|
licenseNotice.isOverLimit
|
||||||
) {
|
) {
|
||||||
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
|
return t("onboarding.serverLicense.upgrade", "Upgrade now →");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translate the label (it's a translation key)
|
// Translate the label (it's a translation key)
|
||||||
const label = button.label ?? '';
|
const label = button.label ?? "";
|
||||||
if (!label) return '';
|
if (!label) return "";
|
||||||
|
|
||||||
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
|
// 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);
|
return t(label, fallback);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderButton = (button: ButtonDefinition) => {
|
const renderButton = (button: ButtonDefinition) => {
|
||||||
const disabled = button.disabledWhen?.(flowState) ?? false;
|
const disabled = button.disabledWhen?.(flowState) ?? false;
|
||||||
|
|
||||||
if (button.type === 'icon') {
|
if (button.type === "icon") {
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
key={button.key}
|
key={button.key}
|
||||||
@@ -70,18 +70,18 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
background: 'var(--onboarding-secondary-button-bg)',
|
background: "var(--onboarding-secondary-button-bg)",
|
||||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
border: "1px solid var(--onboarding-secondary-button-border)",
|
||||||
color: 'var(--onboarding-secondary-button-text)',
|
color: "var(--onboarding-secondary-button-text)",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{button.icon === 'chevron-left' && <ChevronLeftIcon fontSize="small" />}
|
{button.icon === "chevron-left" && <ChevronLeftIcon fontSize="small" />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const variant = button.variant ?? 'secondary';
|
const variant = button.variant ?? "secondary";
|
||||||
const label = resolveButtonLabel(button);
|
const label = resolveButtonLabel(button);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,33 +1,30 @@
|
|||||||
import { useEffect, useMemo, useCallback, useState } from 'react';
|
import { useEffect, useMemo, useCallback, useState } from "react";
|
||||||
import { type StepType } from '@reactour/tour';
|
import { type StepType } from "@reactour/tour";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { isAuthRoute } from '@app/constants/routes';
|
import { isAuthRoute } from "@app/constants/routes";
|
||||||
import { dispatchTourState } from '@app/constants/events';
|
import { dispatchTourState } from "@app/constants/events";
|
||||||
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
|
import { useOnboardingOrchestrator } from "@app/components/onboarding/orchestrator/useOnboardingOrchestrator";
|
||||||
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
|
import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding";
|
||||||
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
|
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from "@app/components/onboarding/OnboardingTour";
|
||||||
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
|
import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide";
|
||||||
import {
|
import { useServerLicenseRequest, useTourRequest } from "@app/components/onboarding/useOnboardingEffects";
|
||||||
useServerLicenseRequest,
|
import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload";
|
||||||
useTourRequest,
|
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
|
||||||
} from '@app/components/onboarding/useOnboardingEffects';
|
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
|
||||||
import { useOnboardingDownload } from '@app/components/onboarding/useOnboardingDownload';
|
import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext";
|
||||||
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext";
|
||||||
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
|
import { createUserStepsConfig } from "@app/components/onboarding/userStepsConfig";
|
||||||
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
import { createAdminStepsConfig } from "@app/components/onboarding/adminStepsConfig";
|
||||||
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
import { createWhatsNewStepsConfig } from "@app/components/onboarding/whatsNewStepsConfig";
|
||||||
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
|
import { removeAllGlows } from "@app/components/onboarding/tourGlow";
|
||||||
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
|
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||||
import { createWhatsNewStepsConfig } from '@app/components/onboarding/whatsNewStepsConfig';
|
import { useServerExperience } from "@app/hooks/useServerExperience";
|
||||||
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
import apiClient from "@app/services/apiClient";
|
||||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
import "@app/components/onboarding/OnboardingTour.css";
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAccountLogout } from "@app/extensions/accountLogout";
|
||||||
import apiClient from '@app/services/apiClient';
|
import { useAuth } from "@app/auth/UseSession";
|
||||||
import '@app/components/onboarding/OnboardingTour.css';
|
|
||||||
import { useAccountLogout } from '@app/extensions/accountLogout';
|
|
||||||
import { useAuth } from '@app/auth/UseSession';
|
|
||||||
|
|
||||||
export default function Onboarding() {
|
export default function Onboarding() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -52,13 +49,16 @@ export default function Onboarding() {
|
|||||||
const accountLogout = useAccountLogout();
|
const accountLogout = useAccountLogout();
|
||||||
const { signOut } = useAuth();
|
const { signOut } = useAuth();
|
||||||
|
|
||||||
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
|
const handleRoleSelect = useCallback(
|
||||||
actions.updateRuntimeState({ selectedRole: role });
|
(role: "admin" | "user" | null) => {
|
||||||
serverExperience.setSelfReportedAdmin(role === 'admin');
|
actions.updateRuntimeState({ selectedRole: role });
|
||||||
}, [actions, serverExperience]);
|
serverExperience.setSelfReportedAdmin(role === "admin");
|
||||||
|
},
|
||||||
|
[actions, serverExperience],
|
||||||
|
);
|
||||||
|
|
||||||
const redirectToLogin = useCallback(() => {
|
const redirectToLogin = useCallback(() => {
|
||||||
window.location.assign('/login');
|
window.location.assign("/login");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePasswordChanged = useCallback(async () => {
|
const handlePasswordChanged = useCallback(async () => {
|
||||||
@@ -80,84 +80,97 @@ export default function Onboarding() {
|
|||||||
}
|
}
|
||||||
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]);
|
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]);
|
||||||
|
|
||||||
const handleAnalyticsChoice = useCallback(async (enableAnalytics: boolean) => {
|
const handleAnalyticsChoice = useCallback(
|
||||||
if (analyticsLoading) return;
|
async (enableAnalytics: boolean) => {
|
||||||
setAnalyticsLoading(true);
|
if (analyticsLoading) return;
|
||||||
setAnalyticsError(null);
|
setAnalyticsLoading(true);
|
||||||
|
setAnalyticsError(null);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('enabled', enableAnalytics.toString());
|
formData.append("enabled", enableAnalytics.toString());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
|
await apiClient.post("/api/v1/settings/update-enable-analytics", formData);
|
||||||
await refetchConfig();
|
await refetchConfig();
|
||||||
setShowAnalyticsModal(false);
|
setShowAnalyticsModal(false);
|
||||||
setAnalyticsModalDismissed(true);
|
setAnalyticsModalDismissed(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAnalyticsError(error instanceof Error ? error.message : 'Unknown error');
|
setAnalyticsError(error instanceof Error ? error.message : "Unknown error");
|
||||||
} finally {
|
} finally {
|
||||||
setAnalyticsLoading(false);
|
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;
|
|
||||||
}
|
}
|
||||||
case 'skip-to-license':
|
},
|
||||||
actions.complete();
|
[analyticsLoading, refetchConfig],
|
||||||
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 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);
|
const [isTourOpen, setIsTourOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
|
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
|
||||||
@@ -167,60 +180,63 @@ export default function Onboarding() {
|
|||||||
const adminTourOrch = useAdminTourOrchestration();
|
const adminTourOrch = useAdminTourOrchestration();
|
||||||
|
|
||||||
const userStepsConfig = useMemo(
|
const userStepsConfig = useMemo(
|
||||||
() => createUserStepsConfig({
|
() =>
|
||||||
t,
|
createUserStepsConfig({
|
||||||
actions: {
|
t,
|
||||||
saveWorkbenchState: tourOrch.saveWorkbenchState,
|
actions: {
|
||||||
closeFilesModal,
|
saveWorkbenchState: tourOrch.saveWorkbenchState,
|
||||||
backToAllTools: tourOrch.backToAllTools,
|
closeFilesModal,
|
||||||
selectCropTool: tourOrch.selectCropTool,
|
backToAllTools: tourOrch.backToAllTools,
|
||||||
loadSampleFile: tourOrch.loadSampleFile,
|
selectCropTool: tourOrch.selectCropTool,
|
||||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
loadSampleFile: tourOrch.loadSampleFile,
|
||||||
pinFile: tourOrch.pinFile,
|
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||||
modifyCropSettings: tourOrch.modifyCropSettings,
|
pinFile: tourOrch.pinFile,
|
||||||
executeTool: tourOrch.executeTool,
|
modifyCropSettings: tourOrch.modifyCropSettings,
|
||||||
openFilesModal,
|
executeTool: tourOrch.executeTool,
|
||||||
},
|
openFilesModal,
|
||||||
}),
|
},
|
||||||
[t, tourOrch, closeFilesModal, openFilesModal]
|
}),
|
||||||
|
[t, tourOrch, closeFilesModal, openFilesModal],
|
||||||
);
|
);
|
||||||
|
|
||||||
const whatsNewStepsConfig = useMemo(
|
const whatsNewStepsConfig = useMemo(
|
||||||
() => createWhatsNewStepsConfig({
|
() =>
|
||||||
t,
|
createWhatsNewStepsConfig({
|
||||||
actions: {
|
t,
|
||||||
saveWorkbenchState: tourOrch.saveWorkbenchState,
|
actions: {
|
||||||
closeFilesModal,
|
saveWorkbenchState: tourOrch.saveWorkbenchState,
|
||||||
backToAllTools: tourOrch.backToAllTools,
|
closeFilesModal,
|
||||||
openFilesModal,
|
backToAllTools: tourOrch.backToAllTools,
|
||||||
loadSampleFile: tourOrch.loadSampleFile,
|
openFilesModal,
|
||||||
switchToViewer: tourOrch.switchToViewer,
|
loadSampleFile: tourOrch.loadSampleFile,
|
||||||
switchToPageEditor: tourOrch.switchToPageEditor,
|
switchToViewer: tourOrch.switchToViewer,
|
||||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
switchToPageEditor: tourOrch.switchToPageEditor,
|
||||||
selectFirstFile: tourOrch.selectFirstFile,
|
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||||
},
|
selectFirstFile: tourOrch.selectFirstFile,
|
||||||
}),
|
},
|
||||||
[t, tourOrch, closeFilesModal, openFilesModal]
|
}),
|
||||||
|
[t, tourOrch, closeFilesModal, openFilesModal],
|
||||||
);
|
);
|
||||||
|
|
||||||
const adminStepsConfig = useMemo(
|
const adminStepsConfig = useMemo(
|
||||||
() => createAdminStepsConfig({
|
() =>
|
||||||
t,
|
createAdminStepsConfig({
|
||||||
actions: {
|
t,
|
||||||
saveAdminState: adminTourOrch.saveAdminState,
|
actions: {
|
||||||
openConfigModal: adminTourOrch.openConfigModal,
|
saveAdminState: adminTourOrch.saveAdminState,
|
||||||
navigateToSection: adminTourOrch.navigateToSection,
|
openConfigModal: adminTourOrch.openConfigModal,
|
||||||
scrollNavToSection: adminTourOrch.scrollNavToSection,
|
navigateToSection: adminTourOrch.navigateToSection,
|
||||||
},
|
scrollNavToSection: adminTourOrch.scrollNavToSection,
|
||||||
}),
|
},
|
||||||
[t, adminTourOrch]
|
}),
|
||||||
|
[t, adminTourOrch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const tourSteps = useMemo<StepType[]>(() => {
|
const tourSteps = useMemo<StepType[]>(() => {
|
||||||
switch (runtimeState.tourType) {
|
switch (runtimeState.tourType) {
|
||||||
case 'admin':
|
case "admin":
|
||||||
return Object.values(adminStepsConfig);
|
return Object.values(adminStepsConfig);
|
||||||
case 'whatsnew':
|
case "whatsnew":
|
||||||
return Object.values(whatsNewStepsConfig);
|
return Object.values(whatsNewStepsConfig);
|
||||||
default:
|
default:
|
||||||
return Object.values(userStepsConfig);
|
return Object.values(userStepsConfig);
|
||||||
@@ -242,8 +258,8 @@ export default function Onboarding() {
|
|||||||
|
|
||||||
// Handle first-login password change modal
|
// Handle first-login password change modal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if(runtimeState.requiresPasswordChange === true) {
|
if (runtimeState.requiresPasswordChange === true) {
|
||||||
console.log('[Onboarding] User requires password change on first login.');
|
console.log("[Onboarding] User requires password change on first login.");
|
||||||
setFirstLoginModalOpen(true);
|
setFirstLoginModalOpen(true);
|
||||||
} else {
|
} else {
|
||||||
setFirstLoginModalOpen(false);
|
setFirstLoginModalOpen(false);
|
||||||
@@ -252,18 +268,18 @@ export default function Onboarding() {
|
|||||||
|
|
||||||
// Handle MFA setup modal
|
// Handle MFA setup modal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if(runtimeState.requiresMfaSetup === true) {
|
if (runtimeState.requiresMfaSetup === true) {
|
||||||
console.log('[Onboarding] User requires MFA setup.');
|
console.log("[Onboarding] User requires MFA setup.");
|
||||||
setMfaModalOpen(true);
|
setMfaModalOpen(true);
|
||||||
} else {
|
} else {
|
||||||
console.log('[Onboarding] User does not require MFA setup.');
|
console.log("[Onboarding] User does not require MFA setup.");
|
||||||
setMfaModalOpen(false);
|
setMfaModalOpen(false);
|
||||||
}
|
}
|
||||||
}, [runtimeState.requiresMfaSetup]);
|
}, [runtimeState.requiresMfaSetup]);
|
||||||
|
|
||||||
const finishTour = useCallback(() => {
|
const finishTour = useCallback(() => {
|
||||||
setIsTourOpen(false);
|
setIsTourOpen(false);
|
||||||
if (runtimeState.tourType === 'admin') {
|
if (runtimeState.tourType === "admin") {
|
||||||
adminTourOrch.restoreAdminState();
|
adminTourOrch.restoreAdminState();
|
||||||
} else {
|
} else {
|
||||||
tourOrch.restoreWorkbenchState();
|
tourOrch.restoreWorkbenchState();
|
||||||
@@ -272,23 +288,29 @@ export default function Onboarding() {
|
|||||||
actions.complete();
|
actions.complete();
|
||||||
}, [actions, adminTourOrch, runtimeState.tourType, tourOrch]);
|
}, [actions, adminTourOrch, runtimeState.tourType, tourOrch]);
|
||||||
|
|
||||||
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
|
const handleAdvanceTour = useCallback(
|
||||||
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
|
(args: AdvanceArgs) => {
|
||||||
if (steps && tourCurrentStep === steps.length - 1) {
|
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
|
||||||
setIsOpen(false);
|
if (steps && tourCurrentStep === steps.length - 1) {
|
||||||
finishTour();
|
setIsOpen(false);
|
||||||
} else if (steps) {
|
finishTour();
|
||||||
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
|
} else if (steps) {
|
||||||
}
|
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
|
||||||
}, [finishTour]);
|
}
|
||||||
|
},
|
||||||
|
[finishTour],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCloseTour = useCallback((args: CloseArgs) => {
|
const handleCloseTour = useCallback(
|
||||||
args.setIsOpen(false);
|
(args: CloseArgs) => {
|
||||||
finishTour();
|
args.setIsOpen(false);
|
||||||
}, [finishTour]);
|
finishTour();
|
||||||
|
},
|
||||||
|
[finishTour],
|
||||||
|
);
|
||||||
|
|
||||||
const currentSlideDefinition = useMemo(() => {
|
const currentSlideDefinition = useMemo(() => {
|
||||||
if (!currentStep || currentStep.type !== 'modal-slide' || !currentStep.slideId) {
|
if (!currentStep || currentStep.type !== "modal-slide" || !currentStep.slideId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
|
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
|
||||||
@@ -312,15 +334,29 @@ export default function Onboarding() {
|
|||||||
analyticsLoading,
|
analyticsLoading,
|
||||||
onMfaSetupComplete: handleMfaSetupComplete,
|
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(() => {
|
const modalSlideCount = useMemo(() => {
|
||||||
return activeFlow.filter((step) => step.type === 'modal-slide').length;
|
return activeFlow.filter((step) => step.type === "modal-slide").length;
|
||||||
}, [activeFlow]);
|
}, [activeFlow]);
|
||||||
|
|
||||||
const currentModalSlideIndex = useMemo(() => {
|
const currentModalSlideIndex = useMemo(() => {
|
||||||
if (!currentStep || currentStep.type !== 'modal-slide') return 0;
|
if (!currentStep || currentStep.type !== "modal-slide") return 0;
|
||||||
const modalSlides = activeFlow.filter((step) => step.type === 'modal-slide');
|
const modalSlides = activeFlow.filter((step) => step.type === "modal-slide");
|
||||||
return modalSlides.findIndex((step) => step.id === currentStep.id);
|
return modalSlides.findIndex((step) => step.id === currentStep.id);
|
||||||
}, [activeFlow, currentStep]);
|
}, [activeFlow, currentStep]);
|
||||||
|
|
||||||
@@ -334,10 +370,10 @@ export default function Onboarding() {
|
|||||||
|
|
||||||
// Show analytics modal before onboarding if needed
|
// Show analytics modal before onboarding if needed
|
||||||
if (showAnalyticsModal) {
|
if (showAnalyticsModal) {
|
||||||
const slideDefinition = SLIDE_DEFINITIONS['analytics-choice'];
|
const slideDefinition = SLIDE_DEFINITIONS["analytics-choice"];
|
||||||
const slideContent = slideDefinition.createSlide({
|
const slideContent = slideDefinition.createSlide({
|
||||||
osLabel: '',
|
osLabel: "",
|
||||||
osUrl: '',
|
osUrl: "",
|
||||||
selectedRole: null,
|
selectedRole: null,
|
||||||
onRoleSelect: () => {},
|
onRoleSelect: () => {},
|
||||||
analyticsError,
|
analyticsError,
|
||||||
@@ -353,9 +389,9 @@ export default function Onboarding() {
|
|||||||
currentModalSlideIndex={0}
|
currentModalSlideIndex={0}
|
||||||
onSkip={() => {}} // No skip allowed
|
onSkip={() => {}} // No skip allowed
|
||||||
onAction={async (action) => {
|
onAction={async (action) => {
|
||||||
if (action === 'enable-analytics') {
|
if (action === "enable-analytics") {
|
||||||
await handleAnalyticsChoice(true);
|
await handleAnalyticsChoice(true);
|
||||||
} else if (action === 'disable-analytics') {
|
} else if (action === "disable-analytics") {
|
||||||
await handleAnalyticsChoice(false);
|
await handleAnalyticsChoice(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -365,10 +401,10 @@ export default function Onboarding() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (firstLoginModalOpen) {
|
if (firstLoginModalOpen) {
|
||||||
const baseSlideDefinition = SLIDE_DEFINITIONS['first-login'];
|
const baseSlideDefinition = SLIDE_DEFINITIONS["first-login"];
|
||||||
const slideContent = baseSlideDefinition.createSlide({
|
const slideContent = baseSlideDefinition.createSlide({
|
||||||
osLabel: '',
|
osLabel: "",
|
||||||
osUrl: '',
|
osUrl: "",
|
||||||
selectedRole: null,
|
selectedRole: null,
|
||||||
onRoleSelect: () => {},
|
onRoleSelect: () => {},
|
||||||
firstLoginUsername: runtimeState.firstLoginUsername,
|
firstLoginUsername: runtimeState.firstLoginUsername,
|
||||||
@@ -385,7 +421,7 @@ export default function Onboarding() {
|
|||||||
currentModalSlideIndex={0}
|
currentModalSlideIndex={0}
|
||||||
onSkip={() => {}}
|
onSkip={() => {}}
|
||||||
onAction={async (action) => {
|
onAction={async (action) => {
|
||||||
if (action === 'complete-close') {
|
if (action === "complete-close") {
|
||||||
handlePasswordChanged();
|
handlePasswordChanged();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -395,11 +431,11 @@ export default function Onboarding() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mfaModalOpen) {
|
if (mfaModalOpen) {
|
||||||
console.log('[Onboarding] Rendering MFA setup modal slide.');
|
console.log("[Onboarding] Rendering MFA setup modal slide.");
|
||||||
const baseSlideDefinition = SLIDE_DEFINITIONS['mfa-setup'];
|
const baseSlideDefinition = SLIDE_DEFINITIONS["mfa-setup"];
|
||||||
const slideContent = baseSlideDefinition.createSlide({
|
const slideContent = baseSlideDefinition.createSlide({
|
||||||
osLabel: '',
|
osLabel: "",
|
||||||
osUrl: '',
|
osUrl: "",
|
||||||
selectedRole: null,
|
selectedRole: null,
|
||||||
onRoleSelect: () => {},
|
onRoleSelect: () => {},
|
||||||
onMfaSetupComplete: handleMfaSetupComplete,
|
onMfaSetupComplete: handleMfaSetupComplete,
|
||||||
@@ -414,7 +450,7 @@ export default function Onboarding() {
|
|||||||
currentModalSlideIndex={0}
|
currentModalSlideIndex={0}
|
||||||
onSkip={() => {}}
|
onSkip={() => {}}
|
||||||
onAction={async (action) => {
|
onAction={async (action) => {
|
||||||
if (action === 'complete-close') {
|
if (action === "complete-close") {
|
||||||
handleMfaSetupComplete();
|
handleMfaSetupComplete();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -424,16 +460,16 @@ export default function Onboarding() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showLicenseSlide) {
|
if (showLicenseSlide) {
|
||||||
const baseSlideDefinition = SLIDE_DEFINITIONS['server-license'];
|
const baseSlideDefinition = SLIDE_DEFINITIONS["server-license"];
|
||||||
// Remove back button for external license notice
|
// Remove back button for external license notice
|
||||||
const slideDefinition = {
|
const slideDefinition = {
|
||||||
...baseSlideDefinition,
|
...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 effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
|
||||||
const slideContent = slideDefinition.createSlide({
|
const slideContent = slideDefinition.createSlide({
|
||||||
osLabel: '',
|
osLabel: "",
|
||||||
osUrl: '',
|
osUrl: "",
|
||||||
osOptions: [],
|
osOptions: [],
|
||||||
onDownloadUrlChange: () => {},
|
onDownloadUrlChange: () => {},
|
||||||
selectedRole: null,
|
selectedRole: null,
|
||||||
@@ -451,9 +487,9 @@ export default function Onboarding() {
|
|||||||
currentModalSlideIndex={0}
|
currentModalSlideIndex={0}
|
||||||
onSkip={closeLicenseSlide}
|
onSkip={closeLicenseSlide}
|
||||||
onAction={(action) => {
|
onAction={(action) => {
|
||||||
if (action === 'see-plans') {
|
if (action === "see-plans") {
|
||||||
closeLicenseSlide();
|
closeLicenseSlide();
|
||||||
navigate('/settings/adminPlan');
|
navigate("/settings/adminPlan");
|
||||||
} else {
|
} else {
|
||||||
closeLicenseSlide();
|
closeLicenseSlide();
|
||||||
}
|
}
|
||||||
@@ -487,10 +523,10 @@ export default function Onboarding() {
|
|||||||
|
|
||||||
// Render the current onboarding step
|
// Render the current onboarding step
|
||||||
switch (currentStep.type) {
|
switch (currentStep.type) {
|
||||||
case 'tool-prompt':
|
case "tool-prompt":
|
||||||
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
|
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
|
||||||
|
|
||||||
case 'modal-slide':
|
case "modal-slide":
|
||||||
if (!currentSlideDefinition || !currentSlideContent) return null;
|
if (!currentSlideDefinition || !currentSlideContent) return null;
|
||||||
return (
|
return (
|
||||||
<OnboardingModalSlide
|
<OnboardingModalSlide
|
||||||
|
|||||||
@@ -5,21 +5,21 @@
|
|||||||
* Handles the hero image, content, stepper, and button actions.
|
* Handles the hero image, content, stepper, and button actions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Modal, Stack, ActionIcon } from '@mantine/core';
|
import { Modal, Stack, ActionIcon } from "@mantine/core";
|
||||||
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
|
||||||
import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
import type { SlideDefinition, ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
|
||||||
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig";
|
||||||
import type { SlideConfig } from '@app/types/types';
|
import type { SlideConfig } from "@app/types/types";
|
||||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
||||||
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
|
import OnboardingStepper from "@app/components/onboarding/OnboardingStepper";
|
||||||
import { SlideButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
|
import { SlideButtons } from "@app/components/onboarding/InitialOnboardingModal/renderButtons";
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { BASE_PATH } from '@app/constants/app';
|
import { BASE_PATH } from "@app/constants/app";
|
||||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
||||||
|
|
||||||
interface OnboardingModalSlideProps {
|
interface OnboardingModalSlideProps {
|
||||||
slideDefinition: SlideDefinition;
|
slideDefinition: SlideDefinition;
|
||||||
@@ -42,9 +42,8 @@ export default function OnboardingModalSlide({
|
|||||||
onAction,
|
onAction,
|
||||||
allowDismiss = true,
|
allowDismiss = true,
|
||||||
}: OnboardingModalSlideProps) {
|
}: OnboardingModalSlideProps) {
|
||||||
|
|
||||||
const renderHero = () => {
|
const renderHero = () => {
|
||||||
if (slideDefinition.hero.type === 'dual-icon') {
|
if (slideDefinition.hero.type === "dual-icon") {
|
||||||
return (
|
return (
|
||||||
<div className={styles.heroIconsContainer}>
|
<div className={styles.heroIconsContainer}>
|
||||||
<div className={styles.iconWrapper}>
|
<div className={styles.iconWrapper}>
|
||||||
@@ -56,20 +55,20 @@ export default function OnboardingModalSlide({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.heroLogoCircle}>
|
<div className={styles.heroLogoCircle}>
|
||||||
{slideDefinition.hero.type === 'rocket' && (
|
{slideDefinition.hero.type === "rocket" && (
|
||||||
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
|
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
|
||||||
)}
|
)}
|
||||||
{slideDefinition.hero.type === 'shield' && (
|
{slideDefinition.hero.type === "shield" && (
|
||||||
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
|
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
|
||||||
)}
|
)}
|
||||||
{slideDefinition.hero.type === 'lock' && (
|
{slideDefinition.hero.type === "lock" && (
|
||||||
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
|
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
|
||||||
)}
|
)}
|
||||||
{slideDefinition.hero.type === 'analytics' && (
|
{slideDefinition.hero.type === "analytics" && (
|
||||||
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
|
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
|
||||||
)}
|
)}
|
||||||
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
|
{slideDefinition.hero.type === "diamond" && <DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />}
|
||||||
{slideDefinition.hero.type === 'logo' && (
|
{slideDefinition.hero.type === "logo" && (
|
||||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -88,8 +87,8 @@ export default function OnboardingModalSlide({
|
|||||||
withCloseButton={false}
|
withCloseButton={false}
|
||||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||||
styles={{
|
styles={{
|
||||||
body: { padding: 0, maxHeight: '90vh', overflow: 'hidden' },
|
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
|
||||||
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)', maxHeight: '90vh' },
|
content: { overflow: "hidden", border: "none", background: "var(--bg-surface)", maxHeight: "90vh" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack gap={0} className={styles.modalContent}>
|
<Stack gap={0} className={styles.modalContent}>
|
||||||
@@ -106,18 +105,18 @@ export default function OnboardingModalSlide({
|
|||||||
radius="md"
|
radius="md"
|
||||||
size={36}
|
size={36}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 16,
|
top: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||||
color: 'white',
|
color: "white",
|
||||||
backdropFilter: 'blur(4px)',
|
backdropFilter: "blur(4px)",
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
}}
|
}}
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
backgroundColor: "rgba(255, 255, 255, 0.3)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -130,12 +129,9 @@ export default function OnboardingModalSlide({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.modalBody} style={{ overflowY: 'auto', maxHeight: 'calc(90vh - 220px)' }}>
|
<div className={styles.modalBody} style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}>
|
||||||
<Stack gap={16}>
|
<Stack gap={16}>
|
||||||
<div
|
<div key={`title-${slideContent.key}`} className={`${styles.title} ${styles.titleText}`}>
|
||||||
key={`title-${slideContent.key}`}
|
|
||||||
className={`${styles.title} ${styles.titleText}`}
|
|
||||||
>
|
|
||||||
{slideContent.title}
|
{slideContent.title}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,9 +142,7 @@ export default function OnboardingModalSlide({
|
|||||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modalSlideCount > 1 && (
|
{modalSlideCount > 1 && <OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />}
|
||||||
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.buttonContainer}>
|
<div className={styles.buttonContainer}>
|
||||||
<SlideButtons
|
<SlideButtons
|
||||||
@@ -164,4 +158,3 @@ export default function OnboardingModalSlide({
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
|
|
||||||
interface OnboardingStepperProps {
|
interface OnboardingStepperProps {
|
||||||
totalSteps: number;
|
totalSteps: number;
|
||||||
@@ -17,18 +17,16 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard
|
|||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{items.map((index) => {
|
{items.map((index) => {
|
||||||
const isActive = index === activeStep;
|
const isActive = index === activeStep;
|
||||||
const baseStyles: React.CSSProperties = {
|
const baseStyles: React.CSSProperties = {
|
||||||
background: isActive
|
background: isActive ? "var(--onboarding-step-active)" : "var(--onboarding-step-inactive)",
|
||||||
? 'var(--onboarding-step-active)'
|
|
||||||
: 'var(--onboarding-step-inactive)',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -48,5 +46,3 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default OnboardingStepper;
|
export default OnboardingStepper;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-glow {
|
@keyframes pulse-glow {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 3px var(--mantine-primary-color-filled),
|
0 0 0 3px var(--mantine-primary-color-filled),
|
||||||
0 0 20px 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 */
|
/* RTL: mirror step indicator and controls in Reactour popovers */
|
||||||
:root[dir='rtl'] .reactour__popover {
|
:root[dir="rtl"] .reactour__popover {
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Minimal overrides retained for glow only */
|
/* Minimal overrides retained for glow only */
|
||||||
|
|
||||||
:root[dir='rtl'] .reactour__badge {
|
:root[dir="rtl"] .reactour__badge {
|
||||||
left: auto;
|
left: auto;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
* when the tour is open but onboarding is inactive.
|
* when the tour is open but onboarding is inactive.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { TourProvider, useTour, type StepType } from '@reactour/tour';
|
import { TourProvider, useTour, type StepType } from "@reactour/tour";
|
||||||
import { CloseButton, ActionIcon } from '@mantine/core';
|
import { CloseButton, ActionIcon } from "@mantine/core";
|
||||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
import CheckIcon from "@mui/icons-material/Check";
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from "i18next";
|
||||||
import i18n from '@app/i18n';
|
import i18n from "@app/i18n";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TourContent - Controls the tour visibility
|
* TourContent - Controls the tour visibility
|
||||||
@@ -49,7 +49,7 @@ interface CloseArgs {
|
|||||||
|
|
||||||
interface OnboardingTourProps {
|
interface OnboardingTourProps {
|
||||||
tourSteps: StepType[];
|
tourSteps: StepType[];
|
||||||
tourType: 'admin' | 'tools' | 'whatsnew';
|
tourType: "admin" | "tools" | "whatsnew";
|
||||||
isRTL: boolean;
|
isRTL: boolean;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -57,22 +57,14 @@ interface OnboardingTourProps {
|
|||||||
onClose: (args: CloseArgs) => void;
|
onClose: (args: CloseArgs) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OnboardingTour({
|
export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen, onAdvance, onClose }: OnboardingTourProps) {
|
||||||
tourSteps,
|
|
||||||
tourType,
|
|
||||||
isRTL,
|
|
||||||
t,
|
|
||||||
isOpen,
|
|
||||||
onAdvance,
|
|
||||||
onClose,
|
|
||||||
}: OnboardingTourProps) {
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TourProvider
|
<TourProvider
|
||||||
key={`${tourType}-${i18n.language}`}
|
key={`${tourType}-${i18n.language}`}
|
||||||
steps={tourSteps}
|
steps={tourSteps}
|
||||||
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
|
maskClassName={tourType === "admin" ? "admin-tour-mask" : undefined}
|
||||||
onClickClose={onClose}
|
onClickClose={onClose}
|
||||||
onClickMask={onAdvance}
|
onClickMask={onAdvance}
|
||||||
onClickHighlighted={(e, clickProps) => {
|
onClickHighlighted={(e, clickProps) => {
|
||||||
@@ -80,10 +72,10 @@ export default function OnboardingTour({
|
|||||||
onAdvance(clickProps);
|
onAdvance(clickProps);
|
||||||
}}
|
}}
|
||||||
keyboardHandler={(e, clickProps, status) => {
|
keyboardHandler={(e, clickProps, status) => {
|
||||||
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
|
if (e.key === "ArrowRight" && !status?.isRightDisabled && clickProps) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onAdvance(clickProps);
|
onAdvance(clickProps);
|
||||||
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
} else if (e.key === "Escape" && !status?.isEscDisabled && clickProps) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onClose(clickProps);
|
onClose(clickProps);
|
||||||
}
|
}
|
||||||
@@ -92,12 +84,12 @@ export default function OnboardingTour({
|
|||||||
styles={{
|
styles={{
|
||||||
popover: (base) => ({
|
popover: (base) => ({
|
||||||
...base,
|
...base,
|
||||||
backgroundColor: 'var(--mantine-color-body)',
|
backgroundColor: "var(--mantine-color-body)",
|
||||||
color: 'var(--mantine-color-text)',
|
color: "var(--mantine-color-text)",
|
||||||
borderRadius: '8px',
|
borderRadius: "8px",
|
||||||
padding: '20px',
|
padding: "20px",
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
||||||
maxWidth: '400px',
|
maxWidth: "400px",
|
||||||
}),
|
}),
|
||||||
maskArea: (base) => ({
|
maskArea: (base) => ({
|
||||||
...base,
|
...base,
|
||||||
@@ -105,11 +97,11 @@ export default function OnboardingTour({
|
|||||||
}),
|
}),
|
||||||
badge: (base) => ({
|
badge: (base) => ({
|
||||||
...base,
|
...base,
|
||||||
backgroundColor: 'var(--mantine-primary-color-filled)',
|
backgroundColor: "var(--mantine-primary-color-filled)",
|
||||||
}),
|
}),
|
||||||
controls: (base) => ({
|
controls: (base) => ({
|
||||||
...base,
|
...base,
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
highlightedMaskClassName="tour-highlight-glow"
|
highlightedMaskClassName="tour-highlight-glow"
|
||||||
@@ -127,7 +119,7 @@ export default function OnboardingTour({
|
|||||||
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })}
|
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
|
aria-label={isLast ? t("onboarding.finish", "Finish") : t("onboarding.next", "Next")}
|
||||||
>
|
>
|
||||||
{isLast ? <CheckIcon /> : <ArrowIcon />}
|
{isLast ? <CheckIcon /> : <ArrowIcon />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -135,10 +127,10 @@ export default function OnboardingTour({
|
|||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
Close: ({ onClick }) => (
|
Close: ({ onClick }) => (
|
||||||
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
|
<CloseButton onClick={onClick} size="md" style={{ position: "absolute", top: "8px", right: "8px" }} />
|
||||||
),
|
),
|
||||||
Content: ({ content }: { content: string }) => (
|
Content: ({ content }: { content: string }) => (
|
||||||
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
|
<div style={{ paddingRight: "16px" }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -148,4 +140,3 @@ export default function OnboardingTour({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type { AdvanceArgs, CloseArgs };
|
export type { AdvanceArgs, CloseArgs };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { StepType } from '@reactour/tour';
|
import type { StepType } from "@reactour/tour";
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from "i18next";
|
||||||
import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow';
|
import { addGlowToElements, removeAllGlows } from "@app/components/onboarding/tourGlow";
|
||||||
|
|
||||||
export enum AdminTourStep {
|
export enum AdminTourStep {
|
||||||
WELCOME,
|
WELCOME,
|
||||||
@@ -32,8 +32,11 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
|||||||
return {
|
return {
|
||||||
[AdminTourStep.WELCOME]: {
|
[AdminTourStep.WELCOME]: {
|
||||||
selector: '[data-tour="config-button"]',
|
selector: '[data-tour="config-button"]',
|
||||||
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
|
content: t(
|
||||||
position: 'right',
|
"adminOnboarding.welcome",
|
||||||
|
"Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators.",
|
||||||
|
),
|
||||||
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => {
|
action: () => {
|
||||||
saveAdminState();
|
saveAdminState();
|
||||||
@@ -41,17 +44,23 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
|||||||
},
|
},
|
||||||
[AdminTourStep.CONFIG_BUTTON]: {
|
[AdminTourStep.CONFIG_BUTTON]: {
|
||||||
selector: '[data-tour="config-button"]',
|
selector: '[data-tour="config-button"]',
|
||||||
content: t('adminOnboarding.configButton', "Click the <strong>Config</strong> button to access all system settings and administrative controls."),
|
content: t(
|
||||||
position: 'right',
|
"adminOnboarding.configButton",
|
||||||
|
"Click the <strong>Config</strong> button to access all system settings and administrative controls.",
|
||||||
|
),
|
||||||
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
actionAfter: () => {
|
actionAfter: () => {
|
||||||
openConfigModal();
|
openConfigModal();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[AdminTourStep.SETTINGS_OVERVIEW]: {
|
[AdminTourStep.SETTINGS_OVERVIEW]: {
|
||||||
selector: '.modal-nav',
|
selector: ".modal-nav",
|
||||||
content: t('adminOnboarding.settingsOverview', "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."),
|
content: t(
|
||||||
position: 'right',
|
"adminOnboarding.settingsOverview",
|
||||||
|
"This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation.",
|
||||||
|
),
|
||||||
|
position: "right",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
action: () => {
|
action: () => {
|
||||||
removeAllGlows();
|
removeAllGlows();
|
||||||
@@ -59,41 +68,68 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
|||||||
},
|
},
|
||||||
[AdminTourStep.TEAMS_AND_USERS]: {
|
[AdminTourStep.TEAMS_AND_USERS]: {
|
||||||
selector: '[data-tour="admin-people-nav"]',
|
selector: '[data-tour="admin-people-nav"]',
|
||||||
highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
|
highlightedSelectors: [
|
||||||
content: t('adminOnboarding.teamsAndUsers', "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
|
'[data-tour="admin-people-nav"]',
|
||||||
position: 'right',
|
'[data-tour="admin-teams-nav"]',
|
||||||
|
'[data-tour="settings-content-area"]',
|
||||||
|
],
|
||||||
|
content: t(
|
||||||
|
"adminOnboarding.teamsAndUsers",
|
||||||
|
"Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.",
|
||||||
|
),
|
||||||
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => {
|
action: () => {
|
||||||
removeAllGlows();
|
removeAllGlows();
|
||||||
navigateToSection('people');
|
navigateToSection("people");
|
||||||
setTimeout(() => {
|
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);
|
}, 100);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
|
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
|
||||||
selector: '[data-tour="admin-adminGeneral-nav"]',
|
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"]'],
|
highlightedSelectors: [
|
||||||
content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."),
|
'[data-tour="admin-adminGeneral-nav"]',
|
||||||
position: 'right',
|
'[data-tour="admin-adminFeatures-nav"]',
|
||||||
|
'[data-tour="admin-adminEndpoints-nav"]',
|
||||||
|
'[data-tour="settings-content-area"]',
|
||||||
|
],
|
||||||
|
content: t(
|
||||||
|
"adminOnboarding.systemCustomization",
|
||||||
|
"We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users.",
|
||||||
|
),
|
||||||
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => {
|
action: () => {
|
||||||
removeAllGlows();
|
removeAllGlows();
|
||||||
navigateToSection('adminGeneral');
|
navigateToSection("adminGeneral");
|
||||||
setTimeout(() => {
|
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);
|
}, 100);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[AdminTourStep.DATABASE_SECTION]: {
|
[AdminTourStep.DATABASE_SECTION]: {
|
||||||
selector: '[data-tour="admin-adminDatabase-nav"]',
|
selector: '[data-tour="admin-adminDatabase-nav"]',
|
||||||
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
|
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
|
||||||
content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."),
|
content: t(
|
||||||
position: 'right',
|
"adminOnboarding.databaseSection",
|
||||||
|
"For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure.",
|
||||||
|
),
|
||||||
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => {
|
action: () => {
|
||||||
removeAllGlows();
|
removeAllGlows();
|
||||||
navigateToSection('adminDatabase');
|
navigateToSection("adminDatabase");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
|
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -102,38 +138,55 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
|||||||
[AdminTourStep.CONNECTIONS_SECTION]: {
|
[AdminTourStep.CONNECTIONS_SECTION]: {
|
||||||
selector: '[data-tour="admin-adminConnections-nav"]',
|
selector: '[data-tour="admin-adminConnections-nav"]',
|
||||||
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
|
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
|
||||||
content: t('adminOnboarding.connectionsSection', "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
|
content: t(
|
||||||
position: 'right',
|
"adminOnboarding.connectionsSection",
|
||||||
|
"The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.",
|
||||||
|
),
|
||||||
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => {
|
action: () => {
|
||||||
removeAllGlows();
|
removeAllGlows();
|
||||||
navigateToSection('adminConnections');
|
navigateToSection("adminConnections");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
|
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
actionAfter: async () => {
|
actionAfter: async () => {
|
||||||
await scrollNavToSection('adminAudit');
|
await scrollNavToSection("adminAudit");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[AdminTourStep.ADMIN_TOOLS]: {
|
[AdminTourStep.ADMIN_TOOLS]: {
|
||||||
selector: '[data-tour="admin-adminAudit-nav"]',
|
selector: '[data-tour="admin-adminAudit-nav"]',
|
||||||
highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
|
highlightedSelectors: [
|
||||||
content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."),
|
'[data-tour="admin-adminAudit-nav"]',
|
||||||
position: 'right',
|
'[data-tour="admin-adminUsage-nav"]',
|
||||||
|
'[data-tour="settings-content-area"]',
|
||||||
|
],
|
||||||
|
content: t(
|
||||||
|
"adminOnboarding.adminTools",
|
||||||
|
"Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform.",
|
||||||
|
),
|
||||||
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => {
|
action: () => {
|
||||||
removeAllGlows();
|
removeAllGlows();
|
||||||
navigateToSection('adminAudit');
|
navigateToSection("adminAudit");
|
||||||
setTimeout(() => {
|
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);
|
}, 100);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[AdminTourStep.WRAP_UP]: {
|
[AdminTourStep.WRAP_UP]: {
|
||||||
selector: '[data-tour="help-button"]',
|
selector: '[data-tour="help-button"]',
|
||||||
content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."),
|
content: t(
|
||||||
position: 'right',
|
"adminOnboarding.wrapUp",
|
||||||
|
"That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu.",
|
||||||
|
),
|
||||||
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => {
|
action: () => {
|
||||||
removeAllGlows();
|
removeAllGlows();
|
||||||
@@ -141,4 +194,3 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
|
import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide";
|
||||||
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
|
import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide";
|
||||||
import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide';
|
import SecurityCheckSlide from "@app/components/onboarding/slides/SecurityCheckSlide";
|
||||||
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
import PlanOverviewSlide from "@app/components/onboarding/slides/PlanOverviewSlide";
|
||||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
import ServerLicenseSlide from "@app/components/onboarding/slides/ServerLicenseSlide";
|
||||||
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
|
import FirstLoginSlide from "@app/components/onboarding/slides/FirstLoginSlide";
|
||||||
import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide';
|
import TourOverviewSlide from "@app/components/onboarding/slides/TourOverviewSlide";
|
||||||
import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide';
|
import AnalyticsChoiceSlide from "@app/components/onboarding/slides/AnalyticsChoiceSlide";
|
||||||
import MFASetupSlide from '@app/components/onboarding/slides/MFASetupSlide';
|
import MFASetupSlide from "@app/components/onboarding/slides/MFASetupSlide";
|
||||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
import { SlideConfig, LicenseNotice } from "@app/types/types";
|
||||||
|
|
||||||
export type SlideId =
|
export type SlideId =
|
||||||
| 'first-login'
|
| "first-login"
|
||||||
| 'welcome'
|
| "welcome"
|
||||||
| 'desktop-install'
|
| "desktop-install"
|
||||||
| 'security-check'
|
| "security-check"
|
||||||
| 'admin-overview'
|
| "admin-overview"
|
||||||
| 'server-license'
|
| "server-license"
|
||||||
| 'tour-overview'
|
| "tour-overview"
|
||||||
| 'analytics-choice'
|
| "analytics-choice"
|
||||||
| 'mfa-setup';
|
| "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 =
|
export type ButtonAction =
|
||||||
| 'next'
|
| "next"
|
||||||
| 'prev'
|
| "prev"
|
||||||
| 'close'
|
| "close"
|
||||||
| 'complete-close'
|
| "complete-close"
|
||||||
| 'download-selected'
|
| "download-selected"
|
||||||
| 'security-next'
|
| "security-next"
|
||||||
| 'launch-admin'
|
| "launch-admin"
|
||||||
| 'launch-tools'
|
| "launch-tools"
|
||||||
| 'launch-auto'
|
| "launch-auto"
|
||||||
| 'see-plans'
|
| "see-plans"
|
||||||
| 'skip-to-license'
|
| "skip-to-license"
|
||||||
| 'skip-tour'
|
| "skip-tour"
|
||||||
| 'enable-analytics'
|
| "enable-analytics"
|
||||||
| 'disable-analytics';
|
| "disable-analytics";
|
||||||
|
|
||||||
export interface FlowState {
|
export interface FlowState {
|
||||||
selectedRole: 'admin' | 'user' | null;
|
selectedRole: "admin" | "user" | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OSOption {
|
export interface OSOption {
|
||||||
@@ -53,8 +53,8 @@ export interface SlideFactoryParams {
|
|||||||
osUrl: string;
|
osUrl: string;
|
||||||
osOptions?: OSOption[];
|
osOptions?: OSOption[];
|
||||||
onDownloadUrlChange?: (url: string) => void;
|
onDownloadUrlChange?: (url: string) => void;
|
||||||
selectedRole: 'admin' | 'user' | null;
|
selectedRole: "admin" | "user" | null;
|
||||||
onRoleSelect: (role: 'admin' | 'user' | null) => void;
|
onRoleSelect: (role: "admin" | "user" | null) => void;
|
||||||
licenseNotice?: LicenseNotice;
|
licenseNotice?: LicenseNotice;
|
||||||
loginEnabled?: boolean;
|
loginEnabled?: boolean;
|
||||||
// First login params
|
// First login params
|
||||||
@@ -72,11 +72,11 @@ export interface HeroDefinition {
|
|||||||
|
|
||||||
export interface ButtonDefinition {
|
export interface ButtonDefinition {
|
||||||
key: string;
|
key: string;
|
||||||
type: 'button' | 'icon';
|
type: "button" | "icon";
|
||||||
label?: string;
|
label?: string;
|
||||||
icon?: 'chevron-left';
|
icon?: "chevron-left";
|
||||||
variant?: 'primary' | 'secondary' | 'default';
|
variant?: "primary" | "secondary" | "default";
|
||||||
group: 'left' | 'right';
|
group: "left" | "right";
|
||||||
action: ButtonAction;
|
action: ButtonAction;
|
||||||
disabledWhen?: (state: FlowState) => boolean;
|
disabledWhen?: (state: FlowState) => boolean;
|
||||||
}
|
}
|
||||||
@@ -89,206 +89,204 @@ export interface SlideDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||||
'first-login': {
|
"first-login": {
|
||||||
id: 'first-login',
|
id: "first-login",
|
||||||
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
||||||
FirstLoginSlide({
|
FirstLoginSlide({
|
||||||
username: firstLoginUsername || '',
|
username: firstLoginUsername || "",
|
||||||
onPasswordChanged: onPasswordChanged || (() => {}),
|
onPasswordChanged: onPasswordChanged || (() => {}),
|
||||||
usingDefaultCredentials: usingDefaultCredentials || false,
|
usingDefaultCredentials: usingDefaultCredentials || false,
|
||||||
}),
|
}),
|
||||||
hero: { type: 'lock' },
|
hero: { type: "lock" },
|
||||||
buttons: [], // Form has its own submit button
|
buttons: [], // Form has its own submit button
|
||||||
},
|
},
|
||||||
'welcome': {
|
welcome: {
|
||||||
id: 'welcome',
|
id: "welcome",
|
||||||
createSlide: () => WelcomeSlide(),
|
createSlide: () => WelcomeSlide(),
|
||||||
hero: { type: 'rocket' },
|
hero: { type: "rocket" },
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
key: 'welcome-next',
|
key: "welcome-next",
|
||||||
type: 'button',
|
type: "button",
|
||||||
label: 'onboarding.buttons.next',
|
label: "onboarding.buttons.next",
|
||||||
variant: 'primary',
|
variant: "primary",
|
||||||
group: 'right',
|
group: "right",
|
||||||
action: 'next',
|
action: "next",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'desktop-install': {
|
"desktop-install": {
|
||||||
id: 'desktop-install',
|
id: "desktop-install",
|
||||||
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
|
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) =>
|
||||||
hero: { type: 'dual-icon' },
|
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
|
||||||
|
hero: { type: "dual-icon" },
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
key: 'desktop-back',
|
key: "desktop-back",
|
||||||
type: 'icon',
|
type: "icon",
|
||||||
icon: 'chevron-left',
|
icon: "chevron-left",
|
||||||
group: 'left',
|
group: "left",
|
||||||
action: 'prev',
|
action: "prev",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'desktop-skip',
|
key: "desktop-skip",
|
||||||
type: 'button',
|
type: "button",
|
||||||
label: 'onboarding.buttons.skipForNow',
|
label: "onboarding.buttons.skipForNow",
|
||||||
variant: 'secondary',
|
variant: "secondary",
|
||||||
group: 'left',
|
group: "left",
|
||||||
action: 'next',
|
action: "next",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'desktop-download',
|
key: "desktop-download",
|
||||||
type: 'button',
|
type: "button",
|
||||||
label: 'onboarding.buttons.download',
|
label: "onboarding.buttons.download",
|
||||||
variant: 'primary',
|
variant: "primary",
|
||||||
group: 'right',
|
group: "right",
|
||||||
action: 'download-selected',
|
action: "download-selected",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'security-check': {
|
"security-check": {
|
||||||
id: 'security-check',
|
id: "security-check",
|
||||||
createSlide: ({ selectedRole, onRoleSelect }) =>
|
createSlide: ({ selectedRole, onRoleSelect }) => SecurityCheckSlide({ selectedRole, onRoleSelect }),
|
||||||
SecurityCheckSlide({ selectedRole, onRoleSelect }),
|
hero: { type: "shield" },
|
||||||
hero: { type: 'shield' },
|
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
key: 'security-back',
|
key: "security-back",
|
||||||
type: 'button',
|
type: "button",
|
||||||
label: 'onboarding.buttons.back',
|
label: "onboarding.buttons.back",
|
||||||
variant: 'secondary',
|
variant: "secondary",
|
||||||
group: 'left',
|
group: "left",
|
||||||
action: 'prev',
|
action: "prev",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'security-next',
|
key: "security-next",
|
||||||
type: 'button',
|
type: "button",
|
||||||
label: 'onboarding.buttons.next',
|
label: "onboarding.buttons.next",
|
||||||
variant: 'primary',
|
variant: "primary",
|
||||||
group: 'right',
|
group: "right",
|
||||||
action: 'security-next',
|
action: "security-next",
|
||||||
disabledWhen: (state) => !state.selectedRole,
|
disabledWhen: (state) => !state.selectedRole,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'admin-overview': {
|
"admin-overview": {
|
||||||
id: 'admin-overview',
|
id: "admin-overview",
|
||||||
createSlide: ({ licenseNotice, loginEnabled }) =>
|
createSlide: ({ licenseNotice, loginEnabled }) => PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
|
||||||
PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
|
hero: { type: "diamond" },
|
||||||
hero: { type: 'diamond' },
|
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
key: 'admin-back',
|
key: "admin-back",
|
||||||
type: 'icon',
|
type: "icon",
|
||||||
icon: 'chevron-left',
|
icon: "chevron-left",
|
||||||
group: 'left',
|
group: "left",
|
||||||
action: 'prev',
|
action: "prev",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'admin-show',
|
key: "admin-show",
|
||||||
type: 'button',
|
type: "button",
|
||||||
label: 'onboarding.buttons.showMeAround',
|
label: "onboarding.buttons.showMeAround",
|
||||||
variant: 'primary',
|
variant: "primary",
|
||||||
group: 'right',
|
group: "right",
|
||||||
action: 'launch-admin',
|
action: "launch-admin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'admin-skip',
|
key: "admin-skip",
|
||||||
type: 'button',
|
type: "button",
|
||||||
label: 'onboarding.buttons.skipTheTour',
|
label: "onboarding.buttons.skipTheTour",
|
||||||
variant: 'secondary',
|
variant: "secondary",
|
||||||
group: 'left',
|
group: "left",
|
||||||
action: 'skip-to-license',
|
action: "skip-to-license",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'server-license': {
|
"server-license": {
|
||||||
id: 'server-license',
|
id: "server-license",
|
||||||
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
|
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
|
||||||
hero: { type: 'dual-icon' },
|
hero: { type: "dual-icon" },
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
key: 'license-back',
|
key: "license-back",
|
||||||
type: 'icon',
|
type: "icon",
|
||||||
icon: 'chevron-left',
|
icon: "chevron-left",
|
||||||
group: 'left',
|
group: "left",
|
||||||
action: 'prev',
|
action: "prev",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'license-close',
|
key: "license-close",
|
||||||
type: 'button',
|
type: "button",
|
||||||
label: 'onboarding.buttons.skipForNow',
|
label: "onboarding.buttons.skipForNow",
|
||||||
variant: 'secondary',
|
variant: "secondary",
|
||||||
group: 'left',
|
group: "left",
|
||||||
action: 'close',
|
action: "close",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'license-see-plans',
|
key: "license-see-plans",
|
||||||
type: 'button',
|
type: "button",
|
||||||
label: 'onboarding.serverLicense.seePlans',
|
label: "onboarding.serverLicense.seePlans",
|
||||||
variant: 'primary',
|
variant: "primary",
|
||||||
group: 'right',
|
group: "right",
|
||||||
action: 'see-plans',
|
action: "see-plans",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'tour-overview': {
|
"tour-overview": {
|
||||||
id: 'tour-overview',
|
id: "tour-overview",
|
||||||
createSlide: () => TourOverviewSlide(),
|
createSlide: () => TourOverviewSlide(),
|
||||||
hero: { type: 'rocket' },
|
hero: { type: "rocket" },
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
key: 'tour-overview-back',
|
key: "tour-overview-back",
|
||||||
type: 'icon',
|
type: "icon",
|
||||||
icon: 'chevron-left',
|
icon: "chevron-left",
|
||||||
group: 'left',
|
group: "left",
|
||||||
action: 'prev',
|
action: "prev",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'tour-overview-skip',
|
key: "tour-overview-skip",
|
||||||
type: 'button',
|
type: "button",
|
||||||
label: 'onboarding.buttons.skipForNow',
|
label: "onboarding.buttons.skipForNow",
|
||||||
variant: 'secondary',
|
variant: "secondary",
|
||||||
group: 'left',
|
group: "left",
|
||||||
action: 'skip-tour',
|
action: "skip-tour",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'tour-overview-show',
|
key: "tour-overview-show",
|
||||||
type: 'button',
|
type: "button",
|
||||||
label: 'onboarding.buttons.showMeAround',
|
label: "onboarding.buttons.showMeAround",
|
||||||
variant: 'primary',
|
variant: "primary",
|
||||||
group: 'right',
|
group: "right",
|
||||||
action: 'launch-tools',
|
action: "launch-tools",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'analytics-choice': {
|
"analytics-choice": {
|
||||||
id: 'analytics-choice',
|
id: "analytics-choice",
|
||||||
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }),
|
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }),
|
||||||
hero: { type: 'analytics' },
|
hero: { type: "analytics" },
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
key: 'analytics-disable',
|
key: "analytics-disable",
|
||||||
type: 'button',
|
type: "button",
|
||||||
label: 'no',
|
label: "no",
|
||||||
variant: 'secondary',
|
variant: "secondary",
|
||||||
group: 'left',
|
group: "left",
|
||||||
action: 'disable-analytics',
|
action: "disable-analytics",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'analytics-enable',
|
key: "analytics-enable",
|
||||||
type: 'button',
|
type: "button",
|
||||||
label: 'yes',
|
label: "yes",
|
||||||
variant: 'primary',
|
variant: "primary",
|
||||||
group: 'right',
|
group: "right",
|
||||||
action: 'enable-analytics',
|
action: "enable-analytics",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'mfa-setup': {
|
"mfa-setup": {
|
||||||
id: 'mfa-setup',
|
id: "mfa-setup",
|
||||||
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }),
|
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }),
|
||||||
hero: { type: 'lock' },
|
hero: { type: "lock" },
|
||||||
buttons: [], // Form has its own submit button
|
buttons: [], // Form has its own submit button
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
export type OnboardingStepId =
|
export type OnboardingStepId =
|
||||||
| 'first-login'
|
| "first-login"
|
||||||
| 'welcome'
|
| "welcome"
|
||||||
| 'desktop-install'
|
| "desktop-install"
|
||||||
| 'security-check'
|
| "security-check"
|
||||||
| 'admin-overview'
|
| "admin-overview"
|
||||||
| 'tool-layout'
|
| "tool-layout"
|
||||||
| 'tour-overview'
|
| "tour-overview"
|
||||||
| 'server-license'
|
| "server-license"
|
||||||
| 'analytics-choice'
|
| "analytics-choice"
|
||||||
| 'mfa-setup';
|
| "mfa-setup";
|
||||||
|
|
||||||
export type OnboardingStepType =
|
export type OnboardingStepType = "modal-slide" | "tool-prompt";
|
||||||
| 'modal-slide'
|
|
||||||
| 'tool-prompt';
|
|
||||||
|
|
||||||
export interface OnboardingRuntimeState {
|
export interface OnboardingRuntimeState {
|
||||||
selectedRole: 'admin' | 'user' | null;
|
selectedRole: "admin" | "user" | null;
|
||||||
tourRequested: boolean;
|
tourRequested: boolean;
|
||||||
tourType: 'admin' | 'tools' | 'whatsnew';
|
tourType: "admin" | "tools" | "whatsnew";
|
||||||
isDesktopApp: boolean;
|
isDesktopApp: boolean;
|
||||||
desktopSlideEnabled: boolean;
|
desktopSlideEnabled: boolean;
|
||||||
analyticsNotConfigured: boolean;
|
analyticsNotConfigured: boolean;
|
||||||
@@ -43,14 +41,23 @@ export interface OnboardingStep {
|
|||||||
id: OnboardingStepId;
|
id: OnboardingStepId;
|
||||||
type: OnboardingStepType;
|
type: OnboardingStepType;
|
||||||
condition: (ctx: OnboardingConditionContext) => boolean;
|
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;
|
allowDismiss?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
||||||
selectedRole: null,
|
selectedRole: null,
|
||||||
tourRequested: false,
|
tourRequested: false,
|
||||||
tourType: 'whatsnew',
|
tourType: "whatsnew",
|
||||||
isDesktopApp: false,
|
isDesktopApp: false,
|
||||||
analyticsNotConfigured: false,
|
analyticsNotConfigured: false,
|
||||||
analyticsEnabled: false,
|
analyticsEnabled: false,
|
||||||
@@ -61,7 +68,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
|||||||
requiresLicense: false,
|
requiresLicense: false,
|
||||||
},
|
},
|
||||||
requiresPasswordChange: false,
|
requiresPasswordChange: false,
|
||||||
firstLoginUsername: '',
|
firstLoginUsername: "",
|
||||||
usingDefaultCredentials: false,
|
usingDefaultCredentials: false,
|
||||||
desktopSlideEnabled: true,
|
desktopSlideEnabled: true,
|
||||||
requiresMfaSetup: false,
|
requiresMfaSetup: false,
|
||||||
@@ -69,59 +76,59 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
|||||||
|
|
||||||
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||||
{
|
{
|
||||||
id: 'first-login',
|
id: "first-login",
|
||||||
type: 'modal-slide',
|
type: "modal-slide",
|
||||||
slideId: 'first-login',
|
slideId: "first-login",
|
||||||
condition: (ctx) => ctx.requiresPasswordChange,
|
condition: (ctx) => ctx.requiresPasswordChange,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'welcome',
|
id: "welcome",
|
||||||
type: 'modal-slide',
|
type: "modal-slide",
|
||||||
slideId: 'welcome',
|
slideId: "welcome",
|
||||||
// Desktop has its own onboarding modal (DesktopOnboardingModal)
|
// Desktop has its own onboarding modal (DesktopOnboardingModal)
|
||||||
condition: (ctx) => !ctx.isDesktopApp,
|
condition: (ctx) => !ctx.isDesktopApp,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'admin-overview',
|
id: "admin-overview",
|
||||||
type: 'modal-slide',
|
type: "modal-slide",
|
||||||
slideId: 'admin-overview',
|
slideId: "admin-overview",
|
||||||
condition: (ctx) => ctx.effectiveIsAdmin,
|
condition: (ctx) => ctx.effectiveIsAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'desktop-install',
|
id: "desktop-install",
|
||||||
type: 'modal-slide',
|
type: "modal-slide",
|
||||||
slideId: 'desktop-install',
|
slideId: "desktop-install",
|
||||||
condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled,
|
condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'security-check',
|
id: "security-check",
|
||||||
type: 'modal-slide',
|
type: "modal-slide",
|
||||||
slideId: 'security-check',
|
slideId: "security-check",
|
||||||
condition: () => false,
|
condition: () => false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tool-layout',
|
id: "tool-layout",
|
||||||
type: 'tool-prompt',
|
type: "tool-prompt",
|
||||||
condition: () => false,
|
condition: () => false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tour-overview',
|
id: "tour-overview",
|
||||||
type: 'modal-slide',
|
type: "modal-slide",
|
||||||
slideId: 'tour-overview',
|
slideId: "tour-overview",
|
||||||
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin' && !ctx.isDesktopApp,
|
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'server-license',
|
id: "server-license",
|
||||||
type: 'modal-slide',
|
type: "modal-slide",
|
||||||
slideId: 'server-license',
|
slideId: "server-license",
|
||||||
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
|
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mfa-setup',
|
id: "mfa-setup",
|
||||||
type: 'modal-slide',
|
type: "modal-slide",
|
||||||
slideId: 'mfa-setup',
|
slideId: "mfa-setup",
|
||||||
condition: (ctx) => ctx.requiresMfaSetup,
|
condition: (ctx) => ctx.requiresMfaSetup,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
|
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
|
||||||
@@ -131,4 +138,3 @@ export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
|
|||||||
export function getStepIndex(id: OnboardingStepId): number {
|
export function getStepIndex(id: OnboardingStepId): number {
|
||||||
return ONBOARDING_STEPS.findIndex((step) => step.id === id);
|
return ONBOARDING_STEPS.findIndex((step) => step.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +1,62 @@
|
|||||||
const STORAGE_PREFIX = 'onboarding';
|
const STORAGE_PREFIX = "onboarding";
|
||||||
const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`;
|
const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`;
|
||||||
const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`;
|
const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`;
|
||||||
|
|
||||||
export function isOnboardingCompleted(): boolean {
|
export function isOnboardingCompleted(): boolean {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === "undefined") return false;
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true';
|
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === "true";
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markOnboardingCompleted(): void {
|
export function markOnboardingCompleted(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true');
|
localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[onboardingStorage] Error marking onboarding as completed:', error);
|
console.error("[onboardingStorage] Error marking onboarding as completed:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetOnboardingProgress(): void {
|
export function resetOnboardingProgress(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
|
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[onboardingStorage] Error resetting onboarding progress:', error);
|
console.error("[onboardingStorage] Error resetting onboarding progress:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasShownToursTooltip(): boolean {
|
export function hasShownToursTooltip(): boolean {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === "undefined") return false;
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(TOURS_TOOLTIP_KEY) === 'true';
|
return localStorage.getItem(TOURS_TOOLTIP_KEY) === "true";
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markToursTooltipShown(): void {
|
export function markToursTooltipShown(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(TOURS_TOOLTIP_KEY, 'true');
|
localStorage.setItem(TOURS_TOOLTIP_KEY, "true");
|
||||||
} catch (error) {
|
} 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 {
|
export function migrateFromLegacyPreferences(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const migrationKey = `${STORAGE_PREFIX}::migrated`;
|
const migrationKey = `${STORAGE_PREFIX}::migrated`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Skip if already migrated
|
// 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) {
|
if (prefsRaw) {
|
||||||
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
|
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export function migrateFromLegacyPreferences(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mark migration complete
|
// Mark migration complete
|
||||||
localStorage.setItem(migrationKey, 'true');
|
localStorage.setItem(migrationKey, "true");
|
||||||
} catch {
|
} catch {
|
||||||
// If migration fails, onboarding will show again - safer than hiding it
|
// If migration fails, onboarding will show again - safer than hiding it
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from "react-router-dom";
|
||||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
import { useServerExperience } from "@app/hooks/useServerExperience";
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ONBOARDING_STEPS,
|
ONBOARDING_STEPS,
|
||||||
@@ -10,39 +10,40 @@ import {
|
|||||||
type OnboardingRuntimeState,
|
type OnboardingRuntimeState,
|
||||||
type OnboardingConditionContext,
|
type OnboardingConditionContext,
|
||||||
DEFAULT_RUNTIME_STATE,
|
DEFAULT_RUNTIME_STATE,
|
||||||
} from '@app/components/onboarding/orchestrator/onboardingConfig';
|
} from "@app/components/onboarding/orchestrator/onboardingConfig";
|
||||||
import {
|
import {
|
||||||
isOnboardingCompleted,
|
isOnboardingCompleted,
|
||||||
markOnboardingCompleted,
|
markOnboardingCompleted,
|
||||||
migrateFromLegacyPreferences,
|
migrateFromLegacyPreferences,
|
||||||
} from '@app/components/onboarding/orchestrator/onboardingStorage';
|
} from "@app/components/onboarding/orchestrator/onboardingStorage";
|
||||||
import { accountService } from '@app/services/accountService';
|
import { accountService } from "@app/services/accountService";
|
||||||
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
|
import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding";
|
||||||
|
|
||||||
const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];
|
const AUTH_ROUTES = ["/login", "/signup", "/auth", "/invite"];
|
||||||
const SESSION_TOUR_REQUESTED = 'onboarding::session::tour-requested';
|
const SESSION_TOUR_REQUESTED = "onboarding::session::tour-requested";
|
||||||
const SESSION_TOUR_TYPE = 'onboarding::session::tour-type';
|
const SESSION_TOUR_TYPE = "onboarding::session::tour-type";
|
||||||
const SESSION_SELECTED_ROLE = 'onboarding::session::selected-role';
|
const SESSION_SELECTED_ROLE = "onboarding::session::selected-role";
|
||||||
|
|
||||||
// Check if user has an auth token (to avoid flash before redirect)
|
// Check if user has an auth token (to avoid flash before redirect)
|
||||||
function hasAuthToken(): boolean {
|
function hasAuthToken(): boolean {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === "undefined") return false;
|
||||||
return !!localStorage.getItem('stirling_jwt');
|
return !!localStorage.getItem("stirling_jwt");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get initial runtime state from session storage (survives remounts)
|
// Get initial runtime state from session storage (survives remounts)
|
||||||
function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState {
|
function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === "undefined") {
|
||||||
return baseState;
|
return baseState;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
|
||||||
const tourType = (sessionTourType === 'admin' || sessionTourType === 'tools' || sessionTourType === 'whatsnew')
|
const tourType =
|
||||||
? sessionTourType
|
sessionTourType === "admin" || sessionTourType === "tools" || sessionTourType === "whatsnew"
|
||||||
: 'whatsnew';
|
? sessionTourType
|
||||||
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
|
: "whatsnew";
|
||||||
|
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as "admin" | "user" | null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...baseState,
|
...baseState,
|
||||||
@@ -56,11 +57,11 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
|
|||||||
}
|
}
|
||||||
|
|
||||||
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (state.tourRequested !== undefined) {
|
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) {
|
if (state.tourType !== undefined) {
|
||||||
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
|
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
|
||||||
@@ -73,12 +74,12 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[useOnboardingOrchestrator] Error persisting runtime state:', error);
|
console.error("[useOnboardingOrchestrator] Error persisting runtime state:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRuntimeStateSession(): void {
|
function clearRuntimeStateSession(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
|
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
|
||||||
@@ -94,9 +95,9 @@ function parseMfaRequired(settings: string | null | undefined): boolean {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(settings) as { mfaRequired?: string };
|
const parsed = JSON.parse(settings) as { mfaRequired?: string };
|
||||||
return parsed.mfaRequired?.toLowerCase() === 'true';
|
return parsed.mfaRequired?.toLowerCase() === "true";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[useOnboardingOrchestrator] Failed to parse account settings JSON:', error);
|
console.warn("[useOnboardingOrchestrator] Failed to parse account settings JSON:", error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,18 +152,14 @@ export interface UseOnboardingOrchestratorOptions {
|
|||||||
defaultRuntimeState?: OnboardingRuntimeState;
|
defaultRuntimeState?: OnboardingRuntimeState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOnboardingOrchestrator(
|
export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOptions): UseOnboardingOrchestratorResult {
|
||||||
options?: UseOnboardingOrchestratorOptions
|
|
||||||
): UseOnboardingOrchestratorResult {
|
|
||||||
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
|
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
|
||||||
const serverExperience = useServerExperience();
|
const serverExperience = useServerExperience();
|
||||||
const { config, loading: configLoading } = useAppConfig();
|
const { config, loading: configLoading } = useAppConfig();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const bypassOnboarding = useBypassOnboarding();
|
const bypassOnboarding = useBypassOnboarding();
|
||||||
|
|
||||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() => getInitialRuntimeState(defaultState));
|
||||||
getInitialRuntimeState(defaultState)
|
|
||||||
);
|
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
||||||
@@ -186,10 +183,10 @@ export function useOnboardingOrchestrator(
|
|||||||
totalUsers: serverExperience.totalUsers,
|
totalUsers: serverExperience.totalUsers,
|
||||||
freeTierLimit: serverExperience.freeTierLimit,
|
freeTierLimit: serverExperience.freeTierLimit,
|
||||||
isOverLimit: serverExperience.overFreeTierLimit ?? false,
|
isOverLimit: serverExperience.overFreeTierLimit ?? false,
|
||||||
requiresLicense: !serverExperience.hasPaidLicense && (
|
requiresLicense:
|
||||||
serverExperience.overFreeTierLimit === true ||
|
!serverExperience.hasPaidLicense &&
|
||||||
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)
|
(serverExperience.overFreeTierLimit === true ||
|
||||||
),
|
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}, [
|
}, [
|
||||||
@@ -220,7 +217,7 @@ export function useOnboardingOrchestrator(
|
|||||||
requiresMfaSetup: parseMfaRequired(accountData.settings),
|
requiresMfaSetup: parseMfaRequired(accountData.settings),
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} 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
|
// 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 isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route));
|
||||||
const loginEnabled = config?.enableLogin === true;
|
const loginEnabled = config?.enableLogin === true;
|
||||||
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
|
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
|
||||||
const shouldBlockOnboarding =
|
const shouldBlockOnboarding = bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
|
||||||
bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
|
|
||||||
|
|
||||||
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
|
const conditionContext = useMemo<OnboardingConditionContext>(
|
||||||
...serverExperience,
|
() => ({
|
||||||
...runtimeState,
|
...serverExperience,
|
||||||
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
|
...runtimeState,
|
||||||
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
|
effectiveIsAdmin:
|
||||||
}), [serverExperience, runtimeState]);
|
serverExperience.effectiveIsAdmin || (!serverExperience.loginEnabled && runtimeState.selectedRole === "admin"),
|
||||||
|
}),
|
||||||
|
[serverExperience, runtimeState],
|
||||||
|
);
|
||||||
|
|
||||||
const activeFlow = useMemo(() => {
|
const activeFlow = useMemo(() => {
|
||||||
return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext));
|
return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext));
|
||||||
}, [conditionContext]);
|
}, [conditionContext]);
|
||||||
|
|
||||||
// Wait for config AND admin status before calculating initial step
|
// Wait for config AND admin status before calculating initial step
|
||||||
const adminStatusResolved = !configLoading && (
|
const adminStatusResolved =
|
||||||
config?.enableLogin === false ||
|
!configLoading && (config?.enableLogin === false || config?.enableLogin === undefined || config?.isAdmin !== undefined);
|
||||||
config?.enableLogin === undefined ||
|
|
||||||
config?.isAdmin !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (configLoading || !adminStatusResolved) return;
|
if (configLoading || !adminStatusResolved) return;
|
||||||
@@ -280,14 +276,15 @@ export function useOnboardingOrchestrator(
|
|||||||
|
|
||||||
const totalSteps = activeFlow.length;
|
const totalSteps = activeFlow.length;
|
||||||
|
|
||||||
const isComplete = isInitialized &&
|
const isComplete = isInitialized && (totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
|
||||||
(totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
|
const currentStep = currentStepIndex >= 0 && currentStepIndex < totalSteps ? activeFlow[currentStepIndex] : null;
|
||||||
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps)
|
|
||||||
? activeFlow[currentStepIndex]
|
|
||||||
: null;
|
|
||||||
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
|
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
|
||||||
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
|
const isLoading =
|
||||||
!initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
|
configLoading ||
|
||||||
|
!adminStatusResolved ||
|
||||||
|
!isInitialized ||
|
||||||
|
!initialIndexSet.current ||
|
||||||
|
(currentStepIndex === -1 && activeFlow.length > 0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!configLoading && !isInitialized) setIsInitialized(true);
|
if (!configLoading && !isInitialized) setIsInitialized(true);
|
||||||
@@ -325,7 +322,6 @@ export function useOnboardingOrchestrator(
|
|||||||
setCurrentStepIndex(nextIndex);
|
setCurrentStepIndex(nextIndex);
|
||||||
}, [currentStepIndex, totalSteps]);
|
}, [currentStepIndex, totalSteps]);
|
||||||
|
|
||||||
|
|
||||||
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
|
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
|
||||||
persistRuntimeState(updates);
|
persistRuntimeState(updates);
|
||||||
setRuntimeState((prev) => ({ ...prev, ...updates }));
|
setRuntimeState((prev) => ({ ...prev, ...updates }));
|
||||||
@@ -336,13 +332,16 @@ export function useOnboardingOrchestrator(
|
|||||||
setCurrentStepIndex(-1);
|
setCurrentStepIndex(-1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startStep = useCallback((stepId: OnboardingStepId) => {
|
const startStep = useCallback(
|
||||||
const index = activeFlow.findIndex((step) => step.id === stepId);
|
(stepId: OnboardingStepId) => {
|
||||||
if (index !== -1) {
|
const index = activeFlow.findIndex((step) => step.id === stepId);
|
||||||
setCurrentStepIndex(index);
|
if (index !== -1) {
|
||||||
setIsPaused(false);
|
setCurrentStepIndex(index);
|
||||||
}
|
setIsPaused(false);
|
||||||
}, [activeFlow]);
|
}
|
||||||
|
},
|
||||||
|
[activeFlow],
|
||||||
|
);
|
||||||
|
|
||||||
const pause = useCallback(() => setIsPaused(true), []);
|
const pause = useCallback(() => setIsPaused(true), []);
|
||||||
const resume = useCallback(() => setIsPaused(false), []);
|
const resume = useCallback(() => setIsPaused(false), []);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Trans } from 'react-i18next';
|
import { Trans } from "react-i18next";
|
||||||
import { Button } from '@mantine/core';
|
import { Button } from "@mantine/core";
|
||||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||||
import i18n from '@app/i18n';
|
import i18n from "@app/i18n";
|
||||||
import { SlideConfig } from '@app/types/types';
|
import { SlideConfig } from "@app/types/types";
|
||||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
||||||
|
|
||||||
interface AnalyticsChoiceSlideProps {
|
interface AnalyticsChoiceSlideProps {
|
||||||
analyticsError?: string | null;
|
analyticsError?: string | null;
|
||||||
@@ -13,8 +13,8 @@ interface AnalyticsChoiceSlideProps {
|
|||||||
|
|
||||||
export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig {
|
export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig {
|
||||||
return {
|
return {
|
||||||
key: 'analytics-choice',
|
key: "analytics-choice",
|
||||||
title: i18n.t('analytics.title', 'Do you want to help make Stirling PDF better?'),
|
title: i18n.t("analytics.title", "Do you want to help make Stirling PDF better?"),
|
||||||
body: (
|
body: (
|
||||||
<div className={styles.bodyCopyInner}>
|
<div className={styles.bodyCopyInner}>
|
||||||
<Trans
|
<Trans
|
||||||
@@ -29,27 +29,22 @@ export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoice
|
|||||||
components={{ strong: <strong /> }}
|
components={{ strong: <strong /> }}
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
<div style={{ textAlign: 'right', marginTop: 0 }}>
|
<div style={{ textAlign: "right", marginTop: 0 }}>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => window.open('https://docs.stirlingpdf.com/analytics-telemetry/', '_blank')}
|
onClick={() => window.open("https://docs.stirlingpdf.com/analytics-telemetry/", "_blank")}
|
||||||
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
|
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
|
||||||
>
|
>
|
||||||
{i18n.t('analytics.learnMore', 'Learn more about our analytics')}
|
{i18n.t("analytics.learnMore", "Learn more about our analytics")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{analyticsError && (
|
{analyticsError && <div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>{analyticsError}</div>}
|
||||||
<div style={{ color: 'var(--mantine-color-red-6)', marginTop: 12 }}>
|
|
||||||
{analyticsError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
background: {
|
background: {
|
||||||
gradientStops: ['#0EA5E9', '#6366F1'],
|
gradientStops: ["#0EA5E9", "#6366F1"],
|
||||||
circles: UNIFIED_CIRCLE_CONFIG,
|
circles: UNIFIED_CIRCLE_CONFIG,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import styles from '@app/components/onboarding/slides/AnimatedSlideBackground.module.css';
|
import styles from "@app/components/onboarding/slides/AnimatedSlideBackground.module.css";
|
||||||
import { AnimatedSlideBackgroundProps } from '@app/types/types';
|
import { AnimatedSlideBackgroundProps } from "@app/types/types";
|
||||||
|
|
||||||
type CircleStyles = React.CSSProperties & {
|
type CircleStyles = React.CSSProperties & {
|
||||||
'--circle-move-x'?: string;
|
"--circle-move-x"?: string;
|
||||||
'--circle-move-y'?: string;
|
"--circle-move-y"?: string;
|
||||||
'--circle-duration'?: string;
|
"--circle-duration"?: string;
|
||||||
'--circle-delay'?: string;
|
"--circle-delay"?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundProps {
|
interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundProps {
|
||||||
@@ -14,11 +14,7 @@ interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundP
|
|||||||
slideKey: string;
|
slideKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AnimatedSlideBackground({
|
export default function AnimatedSlideBackground({ gradientStops, circles, isActive }: AnimatedSlideBackgroundComponentProps) {
|
||||||
gradientStops,
|
|
||||||
circles,
|
|
||||||
isActive,
|
|
||||||
}: AnimatedSlideBackgroundComponentProps) {
|
|
||||||
const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null);
|
const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null);
|
||||||
const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops);
|
const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops);
|
||||||
const [isTransitioning, setIsTransitioning] = React.useState(false);
|
const [isTransitioning, setIsTransitioning] = React.useState(false);
|
||||||
@@ -69,14 +65,14 @@ export default function AnimatedSlideBackground({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`${styles.gradientLayer} ${isActive ? styles.gradientLayerActive : ''}`.trim()}
|
className={`${styles.gradientLayer} ${isActive ? styles.gradientLayerActive : ""}`.trim()}
|
||||||
style={currentGradientStyle}
|
style={currentGradientStyle}
|
||||||
/>
|
/>
|
||||||
{circles.map((circle, index) => {
|
{circles.map((circle, index) => {
|
||||||
const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle;
|
const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle;
|
||||||
|
|
||||||
const moveX = position === 'bottom-left' ? amplitude : -amplitude;
|
const moveX = position === "bottom-left" ? amplitude : -amplitude;
|
||||||
const moveY = position === 'bottom-left' ? -amplitude * 0.6 : amplitude * 0.6;
|
const moveY = position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6;
|
||||||
|
|
||||||
const circleStyle: CircleStyles = {
|
const circleStyle: CircleStyles = {
|
||||||
width: size,
|
width: size,
|
||||||
@@ -84,17 +80,17 @@ export default function AnimatedSlideBackground({
|
|||||||
background: color,
|
background: color,
|
||||||
opacity: opacity ?? 0.9,
|
opacity: opacity ?? 0.9,
|
||||||
filter: blur ? `blur(${blur}px)` : undefined,
|
filter: blur ? `blur(${blur}px)` : undefined,
|
||||||
'--circle-move-x': `${moveX}px`,
|
"--circle-move-x": `${moveX}px`,
|
||||||
'--circle-move-y': `${moveY}px`,
|
"--circle-move-y": `${moveY}px`,
|
||||||
'--circle-duration': `${duration}s`,
|
"--circle-duration": `${duration}s`,
|
||||||
'--circle-delay': `${delay}s`,
|
"--circle-delay": `${delay}s`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultOffset = -size / 2;
|
const defaultOffset = -size / 2;
|
||||||
const offsetX = circle.offsetX ?? 0;
|
const offsetX = circle.offsetX ?? 0;
|
||||||
const offsetY = circle.offsetY ?? 0;
|
const offsetY = circle.offsetY ?? 0;
|
||||||
|
|
||||||
if (position === 'bottom-left') {
|
if (position === "bottom-left") {
|
||||||
circleStyle.left = `${defaultOffset + offsetX}px`;
|
circleStyle.left = `${defaultOffset + offsetX}px`;
|
||||||
circleStyle.bottom = `${defaultOffset + offsetY}px`;
|
circleStyle.bottom = `${defaultOffset + offsetY}px`;
|
||||||
} else {
|
} else {
|
||||||
@@ -102,13 +98,7 @@ export default function AnimatedSlideBackground({
|
|||||||
circleStyle.top = `${defaultOffset + offsetY}px`;
|
circleStyle.top = `${defaultOffset + offsetY}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <div key={`circle-${index}-${position}`} className={styles.circle} style={circleStyle} />;
|
||||||
<div
|
|
||||||
key={`circle-${index}-${position}`}
|
|
||||||
className={styles.circle}
|
|
||||||
style={circleStyle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { SlideConfig } from '@app/types/types';
|
import { SlideConfig } from "@app/types/types";
|
||||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||||
import { DesktopInstallTitle, type OSOption } from '@app/components/onboarding/slides/DesktopInstallTitle';
|
import { DesktopInstallTitle, type OSOption } from "@app/components/onboarding/slides/DesktopInstallTitle";
|
||||||
|
|
||||||
export type { OSOption };
|
export type { OSOption };
|
||||||
|
|
||||||
@@ -19,8 +19,8 @@ const DesktopInstallBody = () => {
|
|||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{t(
|
{t(
|
||||||
'onboarding.desktopInstall.body',
|
"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.',
|
"Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.",
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -32,9 +32,8 @@ export default function DesktopInstallSlide({
|
|||||||
osOptions = [],
|
osOptions = [],
|
||||||
onDownloadUrlChange,
|
onDownloadUrlChange,
|
||||||
}: DesktopInstallSlideProps): SlideConfig {
|
}: DesktopInstallSlideProps): SlideConfig {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: 'desktop-install',
|
key: "desktop-install",
|
||||||
title: (
|
title: (
|
||||||
<DesktopInstallTitle
|
<DesktopInstallTitle
|
||||||
osLabel={osLabel}
|
osLabel={osLabel}
|
||||||
@@ -46,9 +45,8 @@ export default function DesktopInstallSlide({
|
|||||||
body: <DesktopInstallBody />,
|
body: <DesktopInstallBody />,
|
||||||
downloadUrl: osUrl,
|
downloadUrl: osUrl,
|
||||||
background: {
|
background: {
|
||||||
gradientStops: ['#2563EB', '#0EA5E9'],
|
gradientStops: ["#2563EB", "#0EA5E9"],
|
||||||
circles: UNIFIED_CIRCLE_CONFIG,
|
circles: UNIFIED_CIRCLE_CONFIG,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { Menu, ActionIcon } from '@mantine/core';
|
import { Menu, ActionIcon } from "@mantine/core";
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
|
||||||
export interface OSOption {
|
export interface OSOption {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -20,7 +20,7 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
|||||||
osLabel,
|
osLabel,
|
||||||
osUrl,
|
osUrl,
|
||||||
osOptions,
|
osOptions,
|
||||||
onDownloadUrlChange
|
onDownloadUrlChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedOsUrl, setSelectedOsUrl] = React.useState<string>(osUrl);
|
const [selectedOsUrl, setSelectedOsUrl] = React.useState<string>(osUrl);
|
||||||
@@ -29,37 +29,41 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
|||||||
setSelectedOsUrl(osUrl);
|
setSelectedOsUrl(osUrl);
|
||||||
}, [osUrl]);
|
}, [osUrl]);
|
||||||
|
|
||||||
const handleOsSelect = React.useCallback((option: OSOption) => {
|
const handleOsSelect = React.useCallback(
|
||||||
setSelectedOsUrl(option.url);
|
(option: OSOption) => {
|
||||||
onDownloadUrlChange?.(option.url);
|
setSelectedOsUrl(option.url);
|
||||||
}, [onDownloadUrlChange]);
|
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 });
|
(osOptions.length > 0 ? osOptions[0] : { label: osLabel, url: osUrl });
|
||||||
|
|
||||||
const displayLabel = currentOsOption.label || osLabel;
|
const displayLabel = currentOsOption.label || osLabel;
|
||||||
const title = displayLabel
|
const title = displayLabel
|
||||||
? t('onboarding.desktopInstall.titleWithOs', 'Download for {{osLabel}}', { osLabel: displayLabel })
|
? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", { osLabel: displayLabel })
|
||||||
: t('onboarding.desktopInstall.title', 'Download');
|
: t("onboarding.desktopInstall.title", "Download");
|
||||||
|
|
||||||
// If only one option or no options, don't show dropdown
|
// If only one option or no options, don't show dropdown
|
||||||
if (osOptions.length <= 1) {
|
if (osOptions.length <= 1) {
|
||||||
return <div style={{ textAlign: 'center', width: '100%' }}>{title}</div>;
|
return <div style={{ textAlign: "center", width: "100%" }}>{title}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', width: '100%' }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "0.5rem", width: "100%" }}>
|
||||||
<span style={{ whiteSpace: 'nowrap' }}>{title}</span>
|
<span style={{ whiteSpace: "nowrap" }}>{title}</span>
|
||||||
<Menu position="bottom" offset={5} zIndex={10000}>
|
<Menu position="bottom" offset={5} zIndex={10000}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{
|
style={{
|
||||||
background: 'transparent',
|
background: "transparent",
|
||||||
border: 'none',
|
border: "none",
|
||||||
color: 'inherit',
|
color: "inherit",
|
||||||
padding: 0
|
padding: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ExpandMoreIcon fontSize="small" />
|
<ExpandMoreIcon fontSize="small" />
|
||||||
@@ -74,11 +78,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
|||||||
onClick={() => handleOsSelect(option)}
|
onClick={() => handleOsSelect(option)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isSelected
|
backgroundColor: isSelected
|
||||||
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
|
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
|
||||||
: 'transparent',
|
: "transparent",
|
||||||
color: isSelected
|
color: isSelected ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" : "inherit",
|
||||||
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
|
|
||||||
: 'inherit',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -90,4 +92,3 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { Stack, PasswordInput, Button, Alert, Text } from '@mantine/core';
|
import { Stack, PasswordInput, Button, Alert, Text } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { SlideConfig } from '@app/types/types';
|
import { SlideConfig } from "@app/types/types";
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||||
import { accountService } from '@app/services/accountService';
|
import { accountService } from "@app/services/accountService";
|
||||||
import { alert as showToast } from '@app/components/toast';
|
import { alert as showToast } from "@app/components/toast";
|
||||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
||||||
|
|
||||||
interface FirstLoginSlideProps {
|
interface FirstLoginSlideProps {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -14,66 +14,66 @@ interface FirstLoginSlideProps {
|
|||||||
usingDefaultCredentials?: boolean;
|
usingDefaultCredentials?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PASSWORD = 'stirling';
|
const DEFAULT_PASSWORD = "stirling";
|
||||||
|
|
||||||
function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) {
|
function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// If using default credentials, pre-fill with "stirling" - user won't see this field
|
// If using default credentials, pre-fill with "stirling" - user won't see this field
|
||||||
const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : '');
|
const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : "");
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
// Validation
|
// Validation
|
||||||
if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) {
|
if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) {
|
||||||
setError(t('firstLogin.allFieldsRequired', 'All fields are required'));
|
setError(t("firstLogin.allFieldsRequired", "All fields are required"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setError(t('firstLogin.passwordsDoNotMatch', 'New passwords do not match'));
|
setError(t("firstLogin.passwordsDoNotMatch", "New passwords do not match"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 8) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword === currentPassword) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError("");
|
||||||
|
|
||||||
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
|
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
alertType: 'success',
|
alertType: "success",
|
||||||
title: t('firstLogin.passwordChangedSuccess', 'Password changed successfully! Please log in again.')
|
title: t("firstLogin.passwordChangedSuccess", "Password changed successfully! Please log in again."),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear form
|
// Clear form
|
||||||
setCurrentPassword('');
|
setCurrentPassword("");
|
||||||
setNewPassword('');
|
setNewPassword("");
|
||||||
setConfirmPassword('');
|
setConfirmPassword("");
|
||||||
|
|
||||||
// Wait a moment for the user to see the success message
|
// Wait a moment for the user to see the success message
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onPasswordChanged();
|
onPasswordChanged();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to change password:', err);
|
console.error("Failed to change password:", err);
|
||||||
// Extract error message from axios response if available
|
// Extract error message from axios response if available
|
||||||
const axiosError = err as { response?: { data?: { message?: string } } };
|
const axiosError = err as { response?: { data?: { message?: string } } };
|
||||||
setError(
|
setError(
|
||||||
axiosError.response?.data?.message ||
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -85,25 +85,18 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
|||||||
<div className={styles.securityCard}>
|
<div className={styles.securityCard}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<div className={styles.securityAlertRow}>
|
<div className={styles.securityAlertRow}>
|
||||||
<LocalIcon icon="info-rounded" width={20} height={20} style={{ color: '#3B82F6', flexShrink: 0 }} />
|
<LocalIcon icon="info-rounded" width={20} height={20} style={{ color: "#3B82F6", flexShrink: 0 }} />
|
||||||
<span>
|
<span>
|
||||||
{t(
|
{t("firstLogin.welcomeMessage", "For security reasons, you must change your password on your first login.")}
|
||||||
'firstLogin.welcomeMessage',
|
|
||||||
'For security reasons, you must change your password on your first login.'
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{t('firstLogin.loggedInAs', 'Logged in as')}: <strong>{username}</strong>
|
{t("firstLogin.loggedInAs", "Logged in as")}: <strong>{username}</strong>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert
|
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
|
||||||
icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />}
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -111,8 +104,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
|||||||
{/* Only show current password field if not using default credentials */}
|
{/* Only show current password field if not using default credentials */}
|
||||||
{!usingDefaultCredentials && (
|
{!usingDefaultCredentials && (
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t('firstLogin.currentPassword', 'Current Password')}
|
label={t("firstLogin.currentPassword", "Current Password")}
|
||||||
placeholder={t('firstLogin.enterCurrentPassword', 'Enter your current password')}
|
placeholder={t("firstLogin.enterCurrentPassword", "Enter your current password")}
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
|
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
|
||||||
required
|
required
|
||||||
@@ -123,8 +116,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t('firstLogin.newPassword', 'New Password')}
|
label={t("firstLogin.newPassword", "New Password")}
|
||||||
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
|
placeholder={t("firstLogin.enterNewPassword", "Enter new password (min 8 characters)")}
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
||||||
minLength={8}
|
minLength={8}
|
||||||
@@ -135,8 +128,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t('firstLogin.confirmPassword', 'Confirm New Password')}
|
label={t("firstLogin.confirmPassword", "Confirm New Password")}
|
||||||
placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')}
|
placeholder={t("firstLogin.reEnterNewPassword", "Re-enter new password")}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||||
required
|
required
|
||||||
@@ -154,7 +147,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
|||||||
size="md"
|
size="md"
|
||||||
mt="xs"
|
mt="xs"
|
||||||
>
|
>
|
||||||
{t('firstLogin.changePassword', 'Change Password')}
|
{t("firstLogin.changePassword", "Change Password")}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,8 +161,8 @@ export default function FirstLoginSlide({
|
|||||||
usingDefaultCredentials = false,
|
usingDefaultCredentials = false,
|
||||||
}: FirstLoginSlideProps): SlideConfig {
|
}: FirstLoginSlideProps): SlideConfig {
|
||||||
return {
|
return {
|
||||||
key: 'first-login',
|
key: "first-login",
|
||||||
title: 'Set Your Password',
|
title: "Set Your Password",
|
||||||
body: (
|
body: (
|
||||||
<FirstLoginForm
|
<FirstLoginForm
|
||||||
username={username}
|
username={username}
|
||||||
@@ -178,9 +171,8 @@ export default function FirstLoginSlide({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
background: {
|
background: {
|
||||||
gradientStops: ['#059669', '#0891B2'], // Green to teal - security/trust colors
|
gradientStops: ["#059669", "#0891B2"], // Green to teal - security/trust colors
|
||||||
circles: UNIFIED_CIRCLE_CONFIG,
|
circles: UNIFIED_CIRCLE_CONFIG,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { QRCodeSVG } from "qrcode.react";
|
|||||||
import { SlideConfig } from "@app/types/types";
|
import { SlideConfig } from "@app/types/types";
|
||||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||||
import { accountService } from "@app/services/accountService";
|
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 { useAuth } from "@app/auth/UseSession";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { BASE_PATH } from "@app/constants/app";
|
import { BASE_PATH } from "@app/constants/app";
|
||||||
@@ -59,10 +59,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
|||||||
}, [fetchMfaSetup]);
|
}, [fetchMfaSetup]);
|
||||||
|
|
||||||
const redirectToLogin = useCallback(() => {
|
const redirectToLogin = useCallback(() => {
|
||||||
window.location.assign('/login');
|
window.location.assign("/login");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onLogout = useCallback(async() => {
|
const onLogout = useCallback(async () => {
|
||||||
await accountLogout({ signOut, redirectToLogin });
|
await accountLogout({ signOut, redirectToLogin });
|
||||||
}, [accountLogout, redirectToLogin, signOut]);
|
}, [accountLogout, redirectToLogin, signOut]);
|
||||||
|
|
||||||
@@ -84,13 +84,13 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||||
setMfaError(
|
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 {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mfaSetupCode, onMfaSetupComplete]
|
[mfaSetupCode, onMfaSetupComplete],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isReady = Boolean(mfaSetupData);
|
const isReady = Boolean(mfaSetupData);
|
||||||
@@ -179,18 +179,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
|||||||
>
|
>
|
||||||
Regenerate QR code
|
Regenerate QR code
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="submit" loading={submitting} disabled={!isReady || setupComplete || mfaSetupCode.length < 6}>
|
||||||
type="submit"
|
|
||||||
loading={submitting}
|
|
||||||
disabled={!isReady || setupComplete || mfaSetupCode.length < 6}
|
|
||||||
>
|
|
||||||
Enable MFA
|
Enable MFA
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="button" variant="light" onClick={onLogout}>
|
||||||
type="button"
|
|
||||||
variant="light"
|
|
||||||
onClick={onLogout}
|
|
||||||
>
|
|
||||||
Logout
|
Logout
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
import { SlideConfig, LicenseNotice } from "@app/types/types";
|
||||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||||
|
|
||||||
interface PlanOverviewSlideProps {
|
interface PlanOverviewSlideProps {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -16,31 +16,23 @@ const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isAdmin
|
{isAdmin
|
||||||
? t('onboarding.planOverview.adminTitle', 'Admin Overview')
|
? t("onboarding.planOverview.adminTitle", "Admin Overview")
|
||||||
: t('onboarding.planOverview.userTitle', 'Plan Overview')}
|
: t("onboarding.planOverview.userTitle", "Plan Overview")}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({
|
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({ freeTierLimit, loginEnabled }) => {
|
||||||
freeTierLimit,
|
|
||||||
loginEnabled,
|
|
||||||
}) => {
|
|
||||||
const adminBodyKey = loginEnabled
|
const adminBodyKey = loginEnabled
|
||||||
? 'onboarding.planOverview.adminBodyLoginEnabled'
|
? "onboarding.planOverview.adminBodyLoginEnabled"
|
||||||
: 'onboarding.planOverview.adminBodyLoginDisabled';
|
: "onboarding.planOverview.adminBodyLoginDisabled";
|
||||||
|
|
||||||
const defaultValue = loginEnabled
|
const defaultValue = loginEnabled
|
||||||
? 'As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.'
|
? "As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge."
|
||||||
: 'Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.';
|
: "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Trans
|
<Trans i18nKey={adminBodyKey} values={{ freeTierLimit }} components={{ strong: <strong /> }} defaults={defaultValue} />
|
||||||
i18nKey={adminBodyKey}
|
|
||||||
values={{ freeTierLimit }}
|
|
||||||
components={{ strong: <strong /> }}
|
|
||||||
defaults={defaultValue}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,7 +41,7 @@ const UserOverviewBody: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{t(
|
{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.",
|
"Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use.",
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -60,8 +52,7 @@ const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; logi
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
freeTierLimit,
|
freeTierLimit,
|
||||||
loginEnabled,
|
loginEnabled,
|
||||||
}) =>
|
}) => (isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />);
|
||||||
isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />;
|
|
||||||
|
|
||||||
export default function PlanOverviewSlide({
|
export default function PlanOverviewSlide({
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@@ -71,13 +62,12 @@ export default function PlanOverviewSlide({
|
|||||||
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
|
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: isAdmin ? 'admin-overview' : 'plan-overview',
|
key: isAdmin ? "admin-overview" : "plan-overview",
|
||||||
title: <PlanOverviewTitle isAdmin={isAdmin} />,
|
title: <PlanOverviewTitle isAdmin={isAdmin} />,
|
||||||
body: <PlanOverviewBody isAdmin={isAdmin} freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} />,
|
body: <PlanOverviewBody isAdmin={isAdmin} freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} />,
|
||||||
background: {
|
background: {
|
||||||
gradientStops: isAdmin ? ['#4F46E5', '#0EA5E9'] : ['#F97316', '#EF4444'],
|
gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"],
|
||||||
circles: UNIFIED_CIRCLE_CONFIG,
|
circles: UNIFIED_CIRCLE_CONFIG,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,41 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Select } from '@mantine/core';
|
import { Select } from "@mantine/core";
|
||||||
import { SlideConfig } from '@app/types/types';
|
import { SlideConfig } from "@app/types/types";
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||||
import i18n from '@app/i18n';
|
import i18n from "@app/i18n";
|
||||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
||||||
|
|
||||||
interface SecurityCheckSlideProps {
|
interface SecurityCheckSlideProps {
|
||||||
selectedRole: 'admin' | 'user' | null;
|
selectedRole: "admin" | "user" | null;
|
||||||
onRoleSelect: (role: 'admin' | 'user' | null) => void;
|
onRoleSelect: (role: "admin" | "user" | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SecurityCheckSlide({
|
export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: SecurityCheckSlideProps): SlideConfig {
|
||||||
selectedRole,
|
|
||||||
onRoleSelect,
|
|
||||||
}: SecurityCheckSlideProps): SlideConfig {
|
|
||||||
return {
|
return {
|
||||||
key: 'security-check',
|
key: "security-check",
|
||||||
title: 'Security Check',
|
title: "Security Check",
|
||||||
body: (
|
body: (
|
||||||
<div className={styles.securitySlideContent}>
|
<div className={styles.securitySlideContent}>
|
||||||
<div className={styles.securityCard}>
|
<div className={styles.securityCard}>
|
||||||
<div className={styles.securityAlertRow}>
|
<div className={styles.securityAlertRow}>
|
||||||
<LocalIcon icon="error" width={20} height={20} style={{ color: '#F04438', flexShrink: 0 }} />
|
<LocalIcon icon="error" width={20} height={20} style={{ color: "#F04438", flexShrink: 0 }} />
|
||||||
<span>{i18n.t('onboarding.securityCheck.message', 'The application has undergone significant changes recently. Your server admin\'s attention may be required. Please confirm your role to continue.')}</span>
|
<span>
|
||||||
|
{i18n.t(
|
||||||
|
"onboarding.securityCheck.message",
|
||||||
|
"The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue.",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
placeholder="Confirm your role"
|
placeholder="Confirm your role"
|
||||||
value={selectedRole}
|
value={selectedRole}
|
||||||
data={[
|
data={[
|
||||||
{ value: 'admin', label: 'Admin' },
|
{ value: "admin", label: "Admin" },
|
||||||
{ value: 'user', label: 'User' },
|
{ value: "user", label: "User" },
|
||||||
]}
|
]}
|
||||||
onChange={(value) => onRoleSelect((value as 'admin' | 'user') ?? null)}
|
onChange={(value) => onRoleSelect((value as "admin" | "user") ?? null)}
|
||||||
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
|
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
@@ -46,10 +48,8 @@ export default function SecurityCheckSlide({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
background: {
|
background: {
|
||||||
gradientStops: ['#5B21B6', '#2563EB'],
|
gradientStops: ["#5B21B6", "#2563EB"],
|
||||||
circles: UNIFIED_CIRCLE_CONFIG,
|
circles: UNIFIED_CIRCLE_CONFIG,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Trans } from 'react-i18next';
|
import { Trans } from "react-i18next";
|
||||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
import { SlideConfig, LicenseNotice } from "@app/types/types";
|
||||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||||
import i18n from '@app/i18n';
|
import i18n from "@app/i18n";
|
||||||
|
|
||||||
interface ServerLicenseSlideProps {
|
interface ServerLicenseSlideProps {
|
||||||
licenseNotice?: LicenseNotice;
|
licenseNotice?: LicenseNotice;
|
||||||
@@ -17,9 +17,9 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide
|
|||||||
const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
|
const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
|
||||||
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
|
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
|
||||||
const title = isOverLimit
|
const title = isOverLimit
|
||||||
? i18n.t('onboarding.serverLicense.overLimitTitle', 'Server License Needed')
|
? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed")
|
||||||
: i18n.t('onboarding.serverLicense.freeTitle', 'Server License');
|
: i18n.t("onboarding.serverLicense.freeTitle", "Server License");
|
||||||
const key = isOverLimit ? 'server-license-over-limit' : 'server-license';
|
const key = isOverLimit ? "server-license-over-limit" : "server-license";
|
||||||
|
|
||||||
const overLimitBody = (
|
const overLimitBody = (
|
||||||
<Trans
|
<Trans
|
||||||
@@ -50,10 +50,8 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
background: {
|
background: {
|
||||||
gradientStops: isOverLimit ? ['#F472B6', '#8B5CF6'] : ['#F97316', '#F59E0B'],
|
gradientStops: isOverLimit ? ["#F472B6", "#8B5CF6"] : ["#F97316", "#F59E0B"],
|
||||||
circles: UNIFIED_CIRCLE_CONFIG,
|
circles: UNIFIED_CIRCLE_CONFIG,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Trans } from 'react-i18next';
|
import { Trans } from "react-i18next";
|
||||||
import i18n from '@app/i18n';
|
import i18n from "@app/i18n";
|
||||||
import { SlideConfig } from '@app/types/types';
|
import { SlideConfig } from "@app/types/types";
|
||||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
||||||
|
|
||||||
export default function TourOverviewSlide(): SlideConfig {
|
export default function TourOverviewSlide(): SlideConfig {
|
||||||
return {
|
return {
|
||||||
key: 'tour-overview',
|
key: "tour-overview",
|
||||||
title: i18n.t('onboarding.tourOverview.title', 'Tour Overview'),
|
title: i18n.t("onboarding.tourOverview.title", "Tour Overview"),
|
||||||
body: (
|
body: (
|
||||||
<span className={styles.bodyCopyInner}>
|
<span className={styles.bodyCopyInner}>
|
||||||
<Trans
|
<Trans
|
||||||
@@ -19,9 +19,8 @@ export default function TourOverviewSlide(): SlideConfig {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
background: {
|
background: {
|
||||||
gradientStops: ['#2563EB', '#7C3AED'],
|
gradientStops: ["#2563EB", "#7C3AED"],
|
||||||
circles: UNIFIED_CIRCLE_CONFIG,
|
circles: UNIFIED_CIRCLE_CONFIG,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { useTranslation, Trans } from 'react-i18next';
|
import { useTranslation, Trans } from "react-i18next";
|
||||||
import { SlideConfig } from '@app/types/types';
|
import { SlideConfig } from "@app/types/types";
|
||||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
|
||||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||||
|
|
||||||
function WelcomeSlideTitle() {
|
function WelcomeSlideTitle() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={styles.welcomeTitleContainer}>
|
<span className={styles.welcomeTitleContainer}>
|
||||||
{t('onboarding.welcomeSlide.title', 'Welcome to Stirling')}
|
{t("onboarding.welcomeSlide.title", "Welcome to Stirling")}
|
||||||
<span className={styles.v2Badge}>V2</span>
|
<span className={styles.v2Badge}>V2</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -27,13 +27,12 @@ const WelcomeSlideBody = () => (
|
|||||||
|
|
||||||
export default function WelcomeSlide(): SlideConfig {
|
export default function WelcomeSlide(): SlideConfig {
|
||||||
return {
|
return {
|
||||||
key: 'welcome',
|
key: "welcome",
|
||||||
title: <WelcomeSlideTitle />,
|
title: <WelcomeSlideTitle />,
|
||||||
body: <WelcomeSlideBody />,
|
body: <WelcomeSlideBody />,
|
||||||
background: {
|
background: {
|
||||||
gradientStops: ['#7C3AED', '#EC4899'],
|
gradientStops: ["#7C3AED", "#EC4899"],
|
||||||
circles: UNIFIED_CIRCLE_CONFIG,
|
circles: UNIFIED_CIRCLE_CONFIG,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AnimatedCircleConfig } from '@app/types/types';
|
import { AnimatedCircleConfig } from "@app/types/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified circle background configuration used across all onboarding slides.
|
* Unified circle background configuration used across all onboarding slides.
|
||||||
@@ -6,9 +6,9 @@ import { AnimatedCircleConfig } from '@app/types/types';
|
|||||||
*/
|
*/
|
||||||
export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
|
export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
|
||||||
{
|
{
|
||||||
position: 'bottom-left',
|
position: "bottom-left",
|
||||||
size: 270,
|
size: 270,
|
||||||
color: 'rgba(255, 255, 255, 0.25)',
|
color: "rgba(255, 255, 255, 0.25)",
|
||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
amplitude: 24,
|
amplitude: 24,
|
||||||
duration: 4.5,
|
duration: 4.5,
|
||||||
@@ -16,9 +16,9 @@ export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
|
|||||||
offsetY: 14,
|
offsetY: 14,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
position: 'top-right',
|
position: "top-right",
|
||||||
size: 300,
|
size: 300,
|
||||||
color: 'rgba(255, 255, 255, 0.2)',
|
color: "rgba(255, 255, 255, 0.2)",
|
||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
amplitude: 28,
|
amplitude: 28,
|
||||||
duration: 4.5,
|
duration: 4.5,
|
||||||
@@ -27,4 +27,3 @@ export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
|
|||||||
offsetY: 18,
|
offsetY: 18,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,15 @@ export const addGlowToElements = (selectors: string[]) => {
|
|||||||
const element = document.querySelector(selector);
|
const element = document.querySelector(selector);
|
||||||
if (element) {
|
if (element) {
|
||||||
if (selector === '[data-tour="settings-content-area"]') {
|
if (selector === '[data-tour="settings-content-area"]') {
|
||||||
element.classList.add('tour-content-glow');
|
element.classList.add("tour-content-glow");
|
||||||
} else {
|
} else {
|
||||||
element.classList.add('tour-nav-glow');
|
element.classList.add("tour-nav-glow");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeAllGlows = () => {
|
export const removeAllGlows = () => {
|
||||||
document.querySelectorAll('.tour-content-glow').forEach((el) => el.classList.remove('tour-content-glow'));
|
document.querySelectorAll(".tour-content-glow").forEach((el) => el.classList.remove("tour-content-glow"));
|
||||||
document.querySelectorAll('.tour-nav-glow').forEach((el) => el.classList.remove('tour-nav-glow'));
|
document.querySelectorAll(".tour-nav-glow").forEach((el) => el.classList.remove("tour-nav-glow"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from "react-router-dom";
|
||||||
import { markOnboardingCompleted } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
import { markOnboardingCompleted } from "@app/components/onboarding/orchestrator/onboardingStorage";
|
||||||
|
|
||||||
const SESSION_KEY = 'onboarding::bypass-all';
|
const SESSION_KEY = "onboarding::bypass-all";
|
||||||
const PARAM_KEY = 'bypassOnboarding';
|
const PARAM_KEY = "bypassOnboarding";
|
||||||
|
|
||||||
function isTruthy(value: string | null): boolean {
|
function isTruthy(value: string | null): boolean {
|
||||||
return value?.toLowerCase() === 'true';
|
return value?.toLowerCase() === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStoredBypass(): boolean {
|
function readStoredBypass(): boolean {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === "undefined") return false;
|
||||||
try {
|
try {
|
||||||
return sessionStorage.getItem(SESSION_KEY) === 'true';
|
return sessionStorage.getItem(SESSION_KEY) === "true";
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStoredBypass(enabled: boolean): void {
|
function setStoredBypass(enabled: boolean): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
try {
|
try {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
sessionStorage.setItem(SESSION_KEY, 'true');
|
sessionStorage.setItem(SESSION_KEY, "true");
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.removeItem(SESSION_KEY);
|
sessionStorage.removeItem(SESSION_KEY);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
* Encapsulates OS detection and download URL logic for the desktop install slide.
|
* Encapsulates OS detection and download URL logic for the desktop install slide.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
import { useOs } from '@app/hooks/useOs';
|
import { useOs } from "@app/hooks/useOs";
|
||||||
import { DOWNLOAD_URLS } from '@app/constants/downloads';
|
import { DOWNLOAD_URLS } from "@app/constants/downloads";
|
||||||
|
|
||||||
interface OsInfo {
|
interface OsInfo {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -29,30 +29,34 @@ interface UseOnboardingDownloadResult {
|
|||||||
|
|
||||||
export function useOnboardingDownload(): UseOnboardingDownloadResult {
|
export function useOnboardingDownload(): UseOnboardingDownloadResult {
|
||||||
const osType = useOs();
|
const osType = useOs();
|
||||||
const [selectedDownloadUrl, setSelectedDownloadUrl] = useState<string>('');
|
const [selectedDownloadUrl, setSelectedDownloadUrl] = useState<string>("");
|
||||||
|
|
||||||
const osInfo = useMemo<OsInfo>(() => {
|
const osInfo = useMemo<OsInfo>(() => {
|
||||||
switch (osType) {
|
switch (osType) {
|
||||||
case 'windows':
|
case "windows":
|
||||||
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
|
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
|
||||||
case 'mac-apple':
|
case "mac-apple":
|
||||||
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
return { label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
||||||
case 'mac-intel':
|
case "mac-intel":
|
||||||
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
|
return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL };
|
||||||
case 'linux-x64':
|
case "linux-x64":
|
||||||
case 'linux-arm64':
|
case "linux-arm64":
|
||||||
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
|
return { label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS };
|
||||||
default:
|
default:
|
||||||
return { label: '', url: '' };
|
return { label: "", url: "" };
|
||||||
}
|
}
|
||||||
}, [osType]);
|
}, [osType]);
|
||||||
|
|
||||||
const osOptions = useMemo<OsOption[]>(() => [
|
const osOptions = useMemo<OsOption[]>(
|
||||||
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
|
() =>
|
||||||
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
|
[
|
||||||
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
|
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
|
||||||
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
|
{ label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: "mac-apple" },
|
||||||
].filter((opt) => opt.url), []);
|
{ label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL, value: "mac-intel" },
|
||||||
|
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
|
||||||
|
].filter((opt) => opt.url),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize selected URL from detected OS
|
// Initialize selected URL from detected OS
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,7 +68,7 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
|
|||||||
const handleDownloadSelected = useCallback(() => {
|
const handleDownloadSelected = useCallback(() => {
|
||||||
const downloadUrl = selectedDownloadUrl || osInfo.url;
|
const downloadUrl = selectedDownloadUrl || osInfo.url;
|
||||||
if (downloadUrl) {
|
if (downloadUrl) {
|
||||||
window.open(downloadUrl, '_blank', 'noopener');
|
window.open(downloadUrl, "_blank", "noopener");
|
||||||
}
|
}
|
||||||
}, [selectedDownloadUrl, osInfo.url]);
|
}, [selectedDownloadUrl, osInfo.url]);
|
||||||
|
|
||||||
@@ -76,4 +80,3 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
|
|||||||
handleDownloadSelected,
|
handleDownloadSelected,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { useEffect, useCallback, useState } from 'react';
|
import { useEffect, useCallback, useState } from "react";
|
||||||
import {
|
import {
|
||||||
SERVER_LICENSE_REQUEST_EVENT,
|
SERVER_LICENSE_REQUEST_EVENT,
|
||||||
START_TOUR_EVENT,
|
START_TOUR_EVENT,
|
||||||
type ServerLicenseRequestPayload,
|
type ServerLicenseRequestPayload,
|
||||||
type TourType,
|
type TourType,
|
||||||
type StartTourPayload,
|
type StartTourPayload,
|
||||||
} from '@app/constants/events';
|
} from "@app/constants/events";
|
||||||
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig";
|
||||||
|
|
||||||
export function useServerLicenseRequest(): {
|
export function useServerLicenseRequest(): {
|
||||||
showLicenseSlide: boolean;
|
showLicenseSlide: boolean;
|
||||||
licenseNotice: OnboardingRuntimeState['licenseNotice'] | null;
|
licenseNotice: OnboardingRuntimeState["licenseNotice"] | null;
|
||||||
closeLicenseSlide: () => void;
|
closeLicenseSlide: () => void;
|
||||||
} {
|
} {
|
||||||
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
|
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
|
||||||
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState['licenseNotice'] | null>(null);
|
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState["licenseNotice"] | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const handleLicenseRequest = (event: Event) => {
|
const handleLicenseRequest = (event: Event) => {
|
||||||
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
|
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
|
||||||
@@ -51,14 +51,14 @@ export function useTourRequest(): {
|
|||||||
clearTourRequest: () => void;
|
clearTourRequest: () => void;
|
||||||
} {
|
} {
|
||||||
const [tourRequested, setTourRequested] = useState(false);
|
const [tourRequested, setTourRequested] = useState(false);
|
||||||
const [requestedTourType, setRequestedTourType] = useState<TourType>('whatsnew');
|
const [requestedTourType, setRequestedTourType] = useState<TourType>("whatsnew");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const handleTourRequest = (event: Event) => {
|
const handleTourRequest = (event: Event) => {
|
||||||
const { detail } = event as CustomEvent<StartTourPayload>;
|
const { detail } = event as CustomEvent<StartTourPayload>;
|
||||||
setRequestedTourType(detail?.tourType ?? 'whatsnew');
|
setRequestedTourType(detail?.tourType ?? "whatsnew");
|
||||||
setTourRequested(true);
|
setTourRequested(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { StepType } from '@reactour/tour';
|
import type { StepType } from "@reactour/tour";
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
export enum TourStep {
|
export enum TourStep {
|
||||||
ALL_TOOLS,
|
ALL_TOOLS,
|
||||||
@@ -53,8 +53,11 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
|||||||
return {
|
return {
|
||||||
[TourStep.ALL_TOOLS]: {
|
[TourStep.ALL_TOOLS]: {
|
||||||
selector: '[data-tour="tool-panel"]',
|
selector: '[data-tour="tool-panel"]',
|
||||||
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
content: t(
|
||||||
position: 'center',
|
"onboarding.allTools",
|
||||||
|
"This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.",
|
||||||
|
),
|
||||||
|
position: "center",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
action: () => {
|
action: () => {
|
||||||
saveWorkbenchState();
|
saveWorkbenchState();
|
||||||
@@ -64,28 +67,40 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
|||||||
},
|
},
|
||||||
[TourStep.SELECT_CROP_TOOL]: {
|
[TourStep.SELECT_CROP_TOOL]: {
|
||||||
selector: '[data-tour="tool-button-crop"]',
|
selector: '[data-tour="tool-button-crop"]',
|
||||||
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
|
content: t(
|
||||||
position: 'right',
|
"onboarding.selectCropTool",
|
||||||
|
"Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
|
||||||
|
),
|
||||||
|
position: "right",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
actionAfter: () => selectCropTool(),
|
actionAfter: () => selectCropTool(),
|
||||||
},
|
},
|
||||||
[TourStep.TOOL_INTERFACE]: {
|
[TourStep.TOOL_INTERFACE]: {
|
||||||
selector: '[data-tour="tool-panel"]',
|
selector: '[data-tour="tool-panel"]',
|
||||||
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
|
content: t(
|
||||||
position: 'center',
|
"onboarding.toolInterface",
|
||||||
|
"This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
|
||||||
|
),
|
||||||
|
position: "center",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
},
|
},
|
||||||
[TourStep.FILES_BUTTON]: {
|
[TourStep.FILES_BUTTON]: {
|
||||||
selector: '[data-tour="files-button"]',
|
selector: '[data-tour="files-button"]',
|
||||||
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
|
content: t(
|
||||||
position: 'right',
|
"onboarding.filesButton",
|
||||||
|
"The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
|
||||||
|
),
|
||||||
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => openFilesModal(),
|
action: () => openFilesModal(),
|
||||||
},
|
},
|
||||||
[TourStep.FILE_SOURCES]: {
|
[TourStep.FILE_SOURCES]: {
|
||||||
selector: '[data-tour="file-sources"]',
|
selector: '[data-tour="file-sources"]',
|
||||||
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
|
content: t(
|
||||||
position: 'right',
|
"onboarding.fileSources",
|
||||||
|
"You can upload new files or access recent files from here. For the tour, we'll just use a sample file.",
|
||||||
|
),
|
||||||
|
position: "right",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
actionAfter: () => {
|
actionAfter: () => {
|
||||||
loadSampleFile();
|
loadSampleFile();
|
||||||
@@ -94,62 +109,88 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
|||||||
},
|
},
|
||||||
[TourStep.WORKBENCH]: {
|
[TourStep.WORKBENCH]: {
|
||||||
selector: '[data-tour="workbench"]',
|
selector: '[data-tour="workbench"]',
|
||||||
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
|
content: t(
|
||||||
position: 'center',
|
"onboarding.workbench",
|
||||||
|
"This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.",
|
||||||
|
),
|
||||||
|
position: "center",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
},
|
},
|
||||||
[TourStep.ACTIVE_FILES]: {
|
[TourStep.ACTIVE_FILES]: {
|
||||||
selector: '[data-tour="workbench"]',
|
selector: '[data-tour="workbench"]',
|
||||||
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
|
content: t(
|
||||||
position: 'center',
|
"onboarding.activeFiles",
|
||||||
|
"The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process.",
|
||||||
|
),
|
||||||
|
position: "center",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
action: () => switchToActiveFiles(),
|
action: () => switchToActiveFiles(),
|
||||||
},
|
},
|
||||||
[TourStep.FILE_CHECKBOX]: {
|
[TourStep.FILE_CHECKBOX]: {
|
||||||
selector: '[data-tour="file-card-checkbox"]',
|
selector: '[data-tour="file-card-checkbox"]',
|
||||||
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
|
content: t(
|
||||||
position: 'top',
|
"onboarding.fileCheckbox",
|
||||||
|
"Clicking one of the files selects it for processing. You can select multiple files for batch operations.",
|
||||||
|
),
|
||||||
|
position: "top",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
},
|
},
|
||||||
[TourStep.CROP_SETTINGS]: {
|
[TourStep.CROP_SETTINGS]: {
|
||||||
selector: '[data-tour="crop-settings"]',
|
selector: '[data-tour="crop-settings"]',
|
||||||
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
|
content: t(
|
||||||
position: 'left',
|
"onboarding.cropSettings",
|
||||||
|
"Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to.",
|
||||||
|
),
|
||||||
|
position: "left",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => modifyCropSettings(),
|
action: () => modifyCropSettings(),
|
||||||
},
|
},
|
||||||
[TourStep.RUN_BUTTON]: {
|
[TourStep.RUN_BUTTON]: {
|
||||||
selector: '[data-tour="run-button"]',
|
selector: '[data-tour="run-button"]',
|
||||||
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
|
content: t(
|
||||||
position: 'top',
|
"onboarding.runButton",
|
||||||
|
"Once the tool has been configured, this button allows you to run the tool on all the selected PDFs.",
|
||||||
|
),
|
||||||
|
position: "top",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
actionAfter: () => executeTool(),
|
actionAfter: () => executeTool(),
|
||||||
},
|
},
|
||||||
[TourStep.RESULTS]: {
|
[TourStep.RESULTS]: {
|
||||||
selector: '[data-tour="tool-panel"]',
|
selector: '[data-tour="tool-panel"]',
|
||||||
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
|
content: t(
|
||||||
position: 'center',
|
"onboarding.results",
|
||||||
|
"After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. ",
|
||||||
|
),
|
||||||
|
position: "center",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
},
|
},
|
||||||
[TourStep.FILE_REPLACEMENT]: {
|
[TourStep.FILE_REPLACEMENT]: {
|
||||||
selector: '[data-tour="file-card-checkbox"]',
|
selector: '[data-tour="file-card-checkbox"]',
|
||||||
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
|
content: t(
|
||||||
position: 'left',
|
"onboarding.fileReplacement",
|
||||||
|
"The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools.",
|
||||||
|
),
|
||||||
|
position: "left",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
},
|
},
|
||||||
[TourStep.PIN_BUTTON]: {
|
[TourStep.PIN_BUTTON]: {
|
||||||
selector: '[data-tour="file-card-pin"]',
|
selector: '[data-tour="file-card-pin"]',
|
||||||
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
|
content: t(
|
||||||
position: 'left',
|
"onboarding.pinButton",
|
||||||
|
"You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them.",
|
||||||
|
),
|
||||||
|
position: "left",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => pinFile(),
|
action: () => pinFile(),
|
||||||
},
|
},
|
||||||
[TourStep.WRAP_UP]: {
|
[TourStep.WRAP_UP]: {
|
||||||
selector: '[data-tour="help-button"]',
|
selector: '[data-tour="help-button"]',
|
||||||
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
|
content: t(
|
||||||
position: 'right',
|
"onboarding.wrapUp",
|
||||||
|
"You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again.",
|
||||||
|
),
|
||||||
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { StepType } from '@reactour/tour';
|
import type { StepType } from "@reactour/tour";
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 100): Promise<void> {
|
async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 100): Promise<void> {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === "undefined") return;
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
// Immediate hit
|
// Immediate hit
|
||||||
if (document.querySelector(selector)) return;
|
if (document.querySelector(selector)) return;
|
||||||
@@ -20,7 +20,7 @@ async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 1
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise<void> {
|
async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise<void> {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === "undefined") return;
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -29,8 +29,8 @@ async function waitForHighlightable(selector: string, timeoutMs = 7000, interval
|
|||||||
const isVisible = !!el && el.getClientRects().length > 0;
|
const isVisible = !!el && el.getClientRects().length > 0;
|
||||||
if (isVisible || Date.now() - start >= timeoutMs) {
|
if (isVisible || Date.now() - start >= timeoutMs) {
|
||||||
// Nudge Reactour to recalc positions in case layout shifted
|
// Nudge Reactour to recalc positions in case layout shifted
|
||||||
window.dispatchEvent(new Event('resize'));
|
window.dispatchEvent(new Event("resize"));
|
||||||
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -85,10 +85,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
|
|||||||
[WhatsNewTourStep.QUICK_ACCESS]: {
|
[WhatsNewTourStep.QUICK_ACCESS]: {
|
||||||
selector: '[data-tour="quick-access-bar"]',
|
selector: '[data-tour="quick-access-bar"]',
|
||||||
content: t(
|
content: t(
|
||||||
'onboarding.whatsNew.quickAccess',
|
"onboarding.whatsNew.quickAccess",
|
||||||
'Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours.'
|
"Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours.",
|
||||||
),
|
),
|
||||||
position: 'right',
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: () => {
|
action: () => {
|
||||||
saveWorkbenchState();
|
saveWorkbenchState();
|
||||||
@@ -99,19 +99,19 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
|
|||||||
[WhatsNewTourStep.LEFT_PANEL]: {
|
[WhatsNewTourStep.LEFT_PANEL]: {
|
||||||
selector: '[data-tour="tool-panel"]',
|
selector: '[data-tour="tool-panel"]',
|
||||||
content: t(
|
content: t(
|
||||||
'onboarding.whatsNew.leftPanel',
|
"onboarding.whatsNew.leftPanel",
|
||||||
'The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly.'
|
"The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly.",
|
||||||
),
|
),
|
||||||
position: 'center',
|
position: "center",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
},
|
},
|
||||||
[WhatsNewTourStep.FILE_UPLOAD]: {
|
[WhatsNewTourStep.FILE_UPLOAD]: {
|
||||||
selector: '[data-tour="files-button"]',
|
selector: '[data-tour="files-button"]',
|
||||||
content: t(
|
content: t(
|
||||||
'onboarding.whatsNew.fileUpload',
|
"onboarding.whatsNew.fileUpload",
|
||||||
'Use the <strong>Files</strong> button to upload or pick a recent PDF. We will load a sample so you can see the workspace.'
|
"Use the <strong>Files</strong> button to upload or pick a recent PDF. We will load a sample so you can see the workspace.",
|
||||||
),
|
),
|
||||||
position: 'right',
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
openFilesModal();
|
openFilesModal();
|
||||||
@@ -130,10 +130,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
|
|||||||
selector: '[data-tour="right-rail-controls"]',
|
selector: '[data-tour="right-rail-controls"]',
|
||||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||||
content: t(
|
content: t(
|
||||||
'onboarding.whatsNew.rightRail',
|
"onboarding.whatsNew.rightRail",
|
||||||
'The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.'
|
"The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.",
|
||||||
),
|
),
|
||||||
position: 'left',
|
position: "left",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
await waitForElement('[data-tour="right-rail-controls"]', 7000, 100);
|
await waitForElement('[data-tour="right-rail-controls"]', 7000, 100);
|
||||||
@@ -143,10 +143,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
|
|||||||
[WhatsNewTourStep.TOP_BAR]: {
|
[WhatsNewTourStep.TOP_BAR]: {
|
||||||
selector: '[data-tour="view-switcher"]',
|
selector: '[data-tour="view-switcher"]',
|
||||||
content: t(
|
content: t(
|
||||||
'onboarding.whatsNew.topBar',
|
"onboarding.whatsNew.topBar",
|
||||||
'The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>.'
|
"The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>.",
|
||||||
),
|
),
|
||||||
position: 'bottom',
|
position: "bottom",
|
||||||
padding: 8,
|
padding: 8,
|
||||||
// Ensure the switcher has mounted before this step renders
|
// Ensure the switcher has mounted before this step renders
|
||||||
action: async () => {
|
action: async () => {
|
||||||
@@ -157,11 +157,8 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
|
|||||||
},
|
},
|
||||||
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
|
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
|
||||||
selector: '[data-tour="view-switcher"]',
|
selector: '[data-tour="view-switcher"]',
|
||||||
content: t(
|
content: t("onboarding.whatsNew.pageEditorView", "Switch to the Page Editor to reorder, rotate, or delete pages."),
|
||||||
'onboarding.whatsNew.pageEditorView',
|
position: "bottom",
|
||||||
'Switch to the Page Editor to reorder, rotate, or delete pages.'
|
|
||||||
),
|
|
||||||
position: 'bottom',
|
|
||||||
padding: 8,
|
padding: 8,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
switchToPageEditor();
|
switchToPageEditor();
|
||||||
@@ -172,10 +169,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
|
|||||||
[WhatsNewTourStep.ACTIVE_FILES_VIEW]: {
|
[WhatsNewTourStep.ACTIVE_FILES_VIEW]: {
|
||||||
selector: '[data-tour="view-switcher"]',
|
selector: '[data-tour="view-switcher"]',
|
||||||
content: t(
|
content: t(
|
||||||
'onboarding.whatsNew.activeFilesView',
|
"onboarding.whatsNew.activeFilesView",
|
||||||
'Use Active Files to see everything you have open and pick what to work on.'
|
"Use Active Files to see everything you have open and pick what to work on.",
|
||||||
),
|
),
|
||||||
position: 'bottom',
|
position: "bottom",
|
||||||
padding: 8,
|
padding: 8,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
switchToActiveFiles();
|
switchToActiveFiles();
|
||||||
@@ -186,12 +183,11 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
|
|||||||
[WhatsNewTourStep.WRAP_UP]: {
|
[WhatsNewTourStep.WRAP_UP]: {
|
||||||
selector: '[data-tour="help-button"]',
|
selector: '[data-tour="help-button"]',
|
||||||
content: t(
|
content: t(
|
||||||
'onboarding.whatsNew.wrapUp',
|
"onboarding.whatsNew.wrapUp",
|
||||||
'That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour.'
|
"That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour.",
|
||||||
),
|
),
|
||||||
position: 'right',
|
position: "right",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
|
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
|
||||||
import PageSelectionInput from '@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput';
|
import PageSelectionInput from "@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput";
|
||||||
import SelectedPagesDisplay from '@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay';
|
import SelectedPagesDisplay from "@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay";
|
||||||
import PageSelectionSyntaxHint from '@app/components/shared/PageSelectionSyntaxHint';
|
import PageSelectionSyntaxHint from "@app/components/shared/PageSelectionSyntaxHint";
|
||||||
import AdvancedSelectionPanel from '@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel';
|
import AdvancedSelectionPanel from "@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel";
|
||||||
|
|
||||||
interface BulkSelectionPanelProps {
|
interface BulkSelectionPanelProps {
|
||||||
csvInput: string;
|
csvInput: string;
|
||||||
@@ -24,8 +24,8 @@ const BulkSelectionPanel = ({
|
|||||||
const maxPages = displayDocument?.pages?.length ?? 0;
|
const maxPages = displayDocument?.pages?.length ?? 0;
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setCsvInput('');
|
setCsvInput("");
|
||||||
onUpdatePagesFromCSV('');
|
onUpdatePagesFromCSV("");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,11 +41,7 @@ const BulkSelectionPanel = ({
|
|||||||
|
|
||||||
<PageSelectionSyntaxHint input={csvInput} maxPages={maxPages} variant="panel" />
|
<PageSelectionSyntaxHint input={csvInput} maxPages={maxPages} variant="panel" />
|
||||||
|
|
||||||
<SelectedPagesDisplay
|
<SelectedPagesDisplay selectedPageIds={selectedPageIds} displayDocument={displayDocument} syntaxError={null} />
|
||||||
selectedPageIds={selectedPageIds}
|
|
||||||
displayDocument={displayDocument}
|
|
||||||
syntaxError={null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdvancedSelectionPanel
|
<AdvancedSelectionPanel
|
||||||
csvInput={csvInput}
|
csvInput={csvInput}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
import React, { useRef, useEffect, useState, useCallback, useMemo } from "react";
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from "@mantine/core";
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
|
import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
|
||||||
import styles from '@app/components/pageEditor/DragDropGrid.module.css';
|
import styles from "@app/components/pageEditor/DragDropGrid.module.css";
|
||||||
import {
|
import { Z_INDEX_SELECTION_BOX, Z_INDEX_DROP_INDICATOR, Z_INDEX_DRAG_BADGE } from "@app/styles/zIndex";
|
||||||
Z_INDEX_SELECTION_BOX,
|
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||||
Z_INDEX_DROP_INDICATOR,
|
|
||||||
Z_INDEX_DRAG_BADGE,
|
|
||||||
} from '@app/styles/zIndex';
|
|
||||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
@@ -20,7 +16,7 @@ import {
|
|||||||
closestCenter,
|
closestCenter,
|
||||||
useDraggable,
|
useDraggable,
|
||||||
useDroppable,
|
useDroppable,
|
||||||
} from '@dnd-kit/core';
|
} from "@dnd-kit/core";
|
||||||
|
|
||||||
interface DragDropItem {
|
interface DragDropItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,7 +29,17 @@ interface DragDropItem {
|
|||||||
interface DragDropGridProps<T extends DragDropItem> {
|
interface DragDropGridProps<T extends DragDropItem> {
|
||||||
items: T[];
|
items: T[];
|
||||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, activeDragIds: string[], justMoved: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
|
renderItem: (
|
||||||
|
item: T,
|
||||||
|
index: number,
|
||||||
|
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
|
||||||
|
boxSelectedIds: string[],
|
||||||
|
clearBoxSelection: () => void,
|
||||||
|
activeDragIds: string[],
|
||||||
|
justMoved: boolean,
|
||||||
|
dragHandleProps?: any,
|
||||||
|
zoomLevel?: number,
|
||||||
|
) => React.ReactNode;
|
||||||
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
||||||
zoomLevel?: number;
|
zoomLevel?: number;
|
||||||
selectedFileIds?: string[];
|
selectedFileIds?: string[];
|
||||||
@@ -41,7 +47,7 @@ interface DragDropGridProps<T extends DragDropItem> {
|
|||||||
onVisibleItemsChange?: (items: T[]) => void;
|
onVisibleItemsChange?: (items: T[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DropSide = 'left' | 'right' | null;
|
type DropSide = "left" | "right" | null;
|
||||||
|
|
||||||
type ItemRect = { id: string; rect: DOMRect };
|
type ItemRect = { id: string; rect: DOMRect };
|
||||||
|
|
||||||
@@ -131,13 +137,13 @@ function resolveDropHint(
|
|||||||
let dropSide: DropSide;
|
let dropSide: DropSide;
|
||||||
if (cursorX < firstItem.rect.left) {
|
if (cursorX < firstItem.rect.left) {
|
||||||
hoveredItem = firstItem;
|
hoveredItem = firstItem;
|
||||||
dropSide = 'left';
|
dropSide = "left";
|
||||||
} else if (cursorX > lastItem.rect.right) {
|
} else if (cursorX > lastItem.rect.right) {
|
||||||
hoveredItem = lastItem;
|
hoveredItem = lastItem;
|
||||||
dropSide = 'right';
|
dropSide = "right";
|
||||||
} else {
|
} else {
|
||||||
const midpoint = hoveredItem.rect.left + hoveredItem.rect.width / 2;
|
const midpoint = hoveredItem.rect.left + hoveredItem.rect.width / 2;
|
||||||
dropSide = cursorX >= midpoint ? 'right' : 'left';
|
dropSide = cursorX >= midpoint ? "right" : "left";
|
||||||
}
|
}
|
||||||
|
|
||||||
return { hoveredId: hoveredItem.id, dropSide };
|
return { hoveredId: hoveredItem.id, dropSide };
|
||||||
@@ -168,15 +174,15 @@ function resolveTargetIndex<T extends DragDropItem>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (hoveredId) {
|
if (hoveredId) {
|
||||||
const filteredIndex = filteredItems.findIndex(item => item.id === hoveredId);
|
const filteredIndex = filteredItems.findIndex((item) => item.id === hoveredId);
|
||||||
if (filteredIndex !== -1) {
|
if (filteredIndex !== -1) {
|
||||||
const adjustedIndex = filteredIndex + (dropSide === 'right' ? 1 : 0);
|
const adjustedIndex = filteredIndex + (dropSide === "right" ? 1 : 0);
|
||||||
return convertFilteredIndexToOriginal(adjustedIndex);
|
return convertFilteredIndexToOriginal(adjustedIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fallbackIndex !== null && fallbackIndex !== undefined) {
|
if (fallbackIndex !== null && fallbackIndex !== undefined) {
|
||||||
const adjustedIndex = fallbackIndex + (dropSide === 'right' ? 1 : 0);
|
const adjustedIndex = fallbackIndex + (dropSide === "right" ? 1 : 0);
|
||||||
return convertFilteredIndexToOriginal(adjustedIndex);
|
return convertFilteredIndexToOriginal(adjustedIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,15 +200,41 @@ interface DraggableItemProps<T extends DragDropItem> {
|
|||||||
justMoved: boolean;
|
justMoved: boolean;
|
||||||
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
||||||
onUpdateDropTarget: (itemId: string | null) => void;
|
onUpdateDropTarget: (itemId: string | null) => void;
|
||||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, activeDragIds: string[], justMoved: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
|
renderItem: (
|
||||||
|
item: T,
|
||||||
|
index: number,
|
||||||
|
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
|
||||||
|
boxSelectedIds: string[],
|
||||||
|
clearBoxSelection: () => void,
|
||||||
|
activeDragIds: string[],
|
||||||
|
justMoved: boolean,
|
||||||
|
dragHandleProps?: any,
|
||||||
|
zoomLevel?: number,
|
||||||
|
) => React.ReactNode;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
selectedPageIds?: string[];
|
selectedPageIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DraggableItemInner = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
|
const DraggableItemInner = <T extends DragDropItem>({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
itemRefs,
|
||||||
|
boxSelectedPageIds,
|
||||||
|
clearBoxSelection,
|
||||||
|
activeDragIds,
|
||||||
|
justMoved,
|
||||||
|
getThumbnailData,
|
||||||
|
renderItem,
|
||||||
|
onUpdateDropTarget,
|
||||||
|
zoomLevel,
|
||||||
|
}: DraggableItemProps<T>) => {
|
||||||
const isPlaceholder = Boolean(item.isPlaceholder);
|
const isPlaceholder = Boolean(item.isPlaceholder);
|
||||||
const pageNumber = (item as any).pageNumber ?? index + 1;
|
const pageNumber = (item as any).pageNumber ?? index + 1;
|
||||||
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef: setDraggableRef,
|
||||||
|
} = useDraggable({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
disabled: isPlaceholder,
|
disabled: isPlaceholder,
|
||||||
data: {
|
data: {
|
||||||
@@ -215,21 +247,21 @@ const DraggableItemInner = <T extends DragDropItem>({ item, index, itemRefs, box
|
|||||||
}
|
}
|
||||||
|
|
||||||
const element = itemRefs.current.get(item.id);
|
const element = itemRefs.current.get(item.id);
|
||||||
const imgElement = element?.querySelector('img.ph-no-capture') as HTMLImageElement;
|
const imgElement = element?.querySelector("img.ph-no-capture") as HTMLImageElement;
|
||||||
if (imgElement?.src) {
|
if (imgElement?.src) {
|
||||||
return {
|
return {
|
||||||
src: imgElement.src,
|
src: imgElement.src,
|
||||||
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0
|
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
data: { index, pageNumber: index + 1 }
|
data: { index, pageNumber: index + 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify parent when hover state changes
|
// Notify parent when hover state changes
|
||||||
@@ -241,14 +273,27 @@ const DraggableItemInner = <T extends DragDropItem>({ item, index, itemRefs, box
|
|||||||
}
|
}
|
||||||
}, [isOver, item.id, onUpdateDropTarget]);
|
}, [isOver, item.id, onUpdateDropTarget]);
|
||||||
|
|
||||||
const setNodeRef = useCallback((element: HTMLDivElement | null) => {
|
const setNodeRef = useCallback(
|
||||||
setDraggableRef(element);
|
(element: HTMLDivElement | null) => {
|
||||||
setDroppableRef(element);
|
setDraggableRef(element);
|
||||||
}, [setDraggableRef, setDroppableRef]);
|
setDroppableRef(element);
|
||||||
|
},
|
||||||
|
[setDraggableRef, setDroppableRef],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, activeDragIds, justMoved, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)}
|
{renderItem(
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
itemRefs,
|
||||||
|
boxSelectedPageIds,
|
||||||
|
clearBoxSelection,
|
||||||
|
activeDragIds,
|
||||||
|
justMoved,
|
||||||
|
{ ref: setNodeRef, ...attributes, ...listeners },
|
||||||
|
zoomLevel,
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -303,18 +348,17 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const getScrollElement = useCallback(() => {
|
const getScrollElement = useCallback(() => {
|
||||||
return containerRef.current?.closest('[data-scrolling-container]') as HTMLElement | null;
|
return containerRef.current?.closest("[data-scrolling-container]") as HTMLElement | null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Create stable signature for items to ensure useMemo detects changes
|
// Create stable signature for items to ensure useMemo detects changes
|
||||||
const itemsSignature = useMemo(() => items.map(item => item.id).join(','), [items]);
|
const itemsSignature = useMemo(() => items.map((item) => item.id).join(","), [items]);
|
||||||
const selectedFileIdsSignature = useMemo(() => selectedFileIds?.join(',') || '', [selectedFileIds]);
|
const selectedFileIdsSignature = useMemo(() => selectedFileIds?.join(",") || "", [selectedFileIds]);
|
||||||
|
|
||||||
const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => {
|
const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => {
|
||||||
const filtered: T[] = [];
|
const filtered: T[] = [];
|
||||||
const indexMap: number[] = [];
|
const indexMap: number[] = [];
|
||||||
const selectedIds =
|
const selectedIds = selectedFileIds && selectedFileIds.length > 0 ? new Set(selectedFileIds) : null;
|
||||||
selectedFileIds && selectedFileIds.length > 0 ? new Set(selectedFileIds) : null;
|
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
const isPlaceholder = Boolean(item.isPlaceholder);
|
const isPlaceholder = Boolean(item.isPlaceholder);
|
||||||
@@ -322,8 +366,7 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const belongsToVisibleFile =
|
const belongsToVisibleFile = !selectedIds || !item.originalFileId || selectedIds.has(item.originalFileId);
|
||||||
!selectedIds || !item.originalFileId || selectedIds.has(item.originalFileId);
|
|
||||||
|
|
||||||
if (!belongsToVisibleFile) {
|
if (!belongsToVisibleFile) {
|
||||||
return;
|
return;
|
||||||
@@ -337,7 +380,7 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
}, [items, selectedFileIds, itemsSignature, selectedFileIdsSignature]);
|
}, [items, selectedFileIds, itemsSignature, selectedFileIdsSignature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const visibleIdSet = new Set(visibleItems.map(item => item.id));
|
const visibleIdSet = new Set(visibleItems.map((item) => item.id));
|
||||||
itemRefs.current.forEach((_, pageId) => {
|
itemRefs.current.forEach((_, pageId) => {
|
||||||
if (!visibleIdSet.has(pageId)) {
|
if (!visibleIdSet.has(pageId)) {
|
||||||
itemRefs.current.delete(pageId);
|
itemRefs.current.delete(pageId);
|
||||||
@@ -366,7 +409,7 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 10,
|
distance: 10,
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Throttled pointer move handler for drop indicator
|
// Throttled pointer move handler for drop indicator
|
||||||
@@ -395,9 +438,9 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('pointermove', handlePointerMove, { passive: true });
|
window.addEventListener("pointermove", handlePointerMove, { passive: true });
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('pointermove', handlePointerMove);
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
if (rafId !== null) {
|
if (rafId !== null) {
|
||||||
cancelAnimationFrame(rafId);
|
cancelAnimationFrame(rafId);
|
||||||
}
|
}
|
||||||
@@ -439,7 +482,7 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
updateLayout();
|
updateLayout();
|
||||||
|
|
||||||
// Listen for window resize
|
// Listen for window resize
|
||||||
window.addEventListener('resize', updateLayout);
|
window.addEventListener("resize", updateLayout);
|
||||||
|
|
||||||
// Use ResizeObserver for container size changes
|
// Use ResizeObserver for container size changes
|
||||||
const resizeObserver = new ResizeObserver(updateLayout);
|
const resizeObserver = new ResizeObserver(updateLayout);
|
||||||
@@ -448,7 +491,7 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', updateLayout);
|
window.removeEventListener("resize", updateLayout);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, [calculateItemsPerRow, zoomLevel]);
|
}, [calculateItemsPerRow, zoomLevel]);
|
||||||
@@ -481,7 +524,7 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
|
|
||||||
// Re-measure virtualizer when zoom or items per row changes
|
// Re-measure virtualizer when zoom or items per row changes
|
||||||
// Also remeasure when items change (not just length) to handle item additions/removals
|
// Also remeasure when items change (not just length) to handle item additions/removals
|
||||||
const visibleItemsSignature = useMemo(() => visibleItems.map(item => item.id).join(','), [visibleItems]);
|
const visibleItemsSignature = useMemo(() => visibleItems.map((item) => item.id).join(","), [visibleItems]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
rowVirtualizer.measure();
|
rowVirtualizer.measure();
|
||||||
}, [zoomLevel, itemsPerRow, visibleItems.length, visibleItemsSignature, rowVirtualizer]);
|
}, [zoomLevel, itemsPerRow, visibleItems.length, visibleItemsSignature, rowVirtualizer]);
|
||||||
@@ -497,76 +540,77 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Box selection handlers
|
// Box selection handlers
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback(
|
||||||
if (e.button !== 0) return; // Only respond to primary button
|
(e: React.MouseEvent) => {
|
||||||
|
if (e.button !== 0) return; // Only respond to primary button
|
||||||
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const clickTarget = e.target as Node;
|
const clickTarget = e.target as Node;
|
||||||
let clickedPageId: string | null = null;
|
let clickedPageId: string | null = null;
|
||||||
|
|
||||||
itemRefs.current.forEach((element, pageId) => {
|
itemRefs.current.forEach((element, pageId) => {
|
||||||
if (element.contains(clickTarget)) {
|
if (element.contains(clickTarget)) {
|
||||||
clickedPageId = pageId;
|
clickedPageId = pageId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (clickedPageId) {
|
||||||
|
// Clicking directly on a page shouldn't initiate box selection
|
||||||
|
// but clear previous box selection if clicking outside current group
|
||||||
|
if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) {
|
||||||
|
setBoxSelectedPageIds([]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (clickedPageId) {
|
e.preventDefault();
|
||||||
// Clicking directly on a page shouldn't initiate box selection
|
|
||||||
// but clear previous box selection if clicking outside current group
|
|
||||||
if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) {
|
|
||||||
setBoxSelectedPageIds([]);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault();
|
const rect = container.getBoundingClientRect();
|
||||||
|
setIsBoxSelecting(true);
|
||||||
|
setBoxSelectStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||||
|
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||||
|
setBoxSelectedPageIds([]);
|
||||||
|
},
|
||||||
|
[boxSelectedPageIds],
|
||||||
|
);
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
const handleMouseMove = useCallback(
|
||||||
setIsBoxSelecting(true);
|
(e: React.MouseEvent) => {
|
||||||
setBoxSelectStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
if (!isBoxSelecting || !boxSelectStart) return;
|
||||||
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
|
||||||
setBoxSelectedPageIds([]);
|
|
||||||
}, [boxSelectedPageIds]);
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
if (!isBoxSelecting || !boxSelectStart) return;
|
if (!rect) return;
|
||||||
|
|
||||||
const rect = containerRef.current?.getBoundingClientRect();
|
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||||
if (!rect) return;
|
|
||||||
|
|
||||||
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
// Calculate which pages intersect with selection box
|
||||||
|
const boxLeft = Math.min(boxSelectStart.x, e.clientX - rect.left);
|
||||||
|
const boxRight = Math.max(boxSelectStart.x, e.clientX - rect.left);
|
||||||
|
const boxTop = Math.min(boxSelectStart.y, e.clientY - rect.top);
|
||||||
|
const boxBottom = Math.max(boxSelectStart.y, e.clientY - rect.top);
|
||||||
|
|
||||||
// Calculate which pages intersect with selection box
|
const selectedIds: string[] = [];
|
||||||
const boxLeft = Math.min(boxSelectStart.x, e.clientX - rect.left);
|
itemRefs.current.forEach((pageEl, pageId) => {
|
||||||
const boxRight = Math.max(boxSelectStart.x, e.clientX - rect.left);
|
const pageRect = pageEl.getBoundingClientRect();
|
||||||
const boxTop = Math.min(boxSelectStart.y, e.clientY - rect.top);
|
const pageLeft = pageRect.left - rect.left;
|
||||||
const boxBottom = Math.max(boxSelectStart.y, e.clientY - rect.top);
|
const pageRight = pageRect.right - rect.left;
|
||||||
|
const pageTop = pageRect.top - rect.top;
|
||||||
|
const pageBottom = pageRect.bottom - rect.top;
|
||||||
|
|
||||||
const selectedIds: string[] = [];
|
// Check if page intersects with selection box
|
||||||
itemRefs.current.forEach((pageEl, pageId) => {
|
const intersects = !(pageRight < boxLeft || pageLeft > boxRight || pageBottom < boxTop || pageTop > boxBottom);
|
||||||
const pageRect = pageEl.getBoundingClientRect();
|
|
||||||
const pageLeft = pageRect.left - rect.left;
|
|
||||||
const pageRight = pageRect.right - rect.left;
|
|
||||||
const pageTop = pageRect.top - rect.top;
|
|
||||||
const pageBottom = pageRect.bottom - rect.top;
|
|
||||||
|
|
||||||
// Check if page intersects with selection box
|
if (intersects) {
|
||||||
const intersects = !(
|
selectedIds.push(pageId);
|
||||||
pageRight < boxLeft ||
|
}
|
||||||
pageLeft > boxRight ||
|
});
|
||||||
pageBottom < boxTop ||
|
|
||||||
pageTop > boxBottom
|
|
||||||
);
|
|
||||||
|
|
||||||
if (intersects) {
|
setBoxSelectedPageIds(selectedIds);
|
||||||
selectedIds.push(pageId);
|
},
|
||||||
}
|
[isBoxSelecting, boxSelectStart],
|
||||||
});
|
);
|
||||||
|
|
||||||
setBoxSelectedPageIds(selectedIds);
|
|
||||||
}, [isBoxSelecting, boxSelectStart]);
|
|
||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
const handleMouseUp = useCallback(() => {
|
||||||
if (isBoxSelecting) {
|
if (isBoxSelecting) {
|
||||||
@@ -601,7 +645,6 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
setDragPreview(null);
|
setDragPreview(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// Handle drag cancel
|
// Handle drag cancel
|
||||||
const handleDragCancel = useCallback(() => {
|
const handleDragCancel = useCallback(() => {
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
@@ -611,64 +654,76 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle drag end
|
// Handle drag end
|
||||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
const handleDragEnd = useCallback(
|
||||||
const { active, over } = event;
|
(event: DragEndEvent) => {
|
||||||
const finalDropSide = dropSide;
|
const { active, over } = event;
|
||||||
setActiveId(null);
|
const finalDropSide = dropSide;
|
||||||
setDragPreview(null);
|
setActiveId(null);
|
||||||
setHoveredItemId(null);
|
setDragPreview(null);
|
||||||
setDropSide(null);
|
setHoveredItemId(null);
|
||||||
|
setDropSide(null);
|
||||||
|
|
||||||
if (!over || active.id === over.id) {
|
if (!over || active.id === over.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeData = active.data.current;
|
const activeData = active.data.current;
|
||||||
if (!activeData) {
|
if (!activeData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourcePageNumber = activeData.pageNumber;
|
const sourcePageNumber = activeData.pageNumber;
|
||||||
|
|
||||||
const overData = over?.data.current;
|
const overData = over?.data.current;
|
||||||
let targetIndex = resolveTargetIndex(
|
let targetIndex = resolveTargetIndex(
|
||||||
|
hoveredItemId,
|
||||||
|
finalDropSide,
|
||||||
|
visibleItems,
|
||||||
|
filteredToOriginalIndex,
|
||||||
|
items.length,
|
||||||
|
overData ? overData.index : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targetIndex === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetIndex < 0) targetIndex = 0;
|
||||||
|
if (targetIndex > items.length) targetIndex = items.length;
|
||||||
|
|
||||||
|
// Check if this page is box-selected
|
||||||
|
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
|
||||||
|
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
|
||||||
|
|
||||||
|
// Call reorder with page number and target index
|
||||||
|
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
|
||||||
|
|
||||||
|
// Highlight moved pages briefly
|
||||||
|
const movedIds = pagesToDrag ?? [active.id as string];
|
||||||
|
setJustMovedIds(movedIds);
|
||||||
|
if (highlightTimeoutRef.current) {
|
||||||
|
window.clearTimeout(highlightTimeoutRef.current);
|
||||||
|
}
|
||||||
|
highlightTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setJustMovedIds([]);
|
||||||
|
highlightTimeoutRef.current = null;
|
||||||
|
}, 1200);
|
||||||
|
|
||||||
|
// Clear box selection after drag
|
||||||
|
if (pagesToDrag) {
|
||||||
|
clearBoxSelection();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
boxSelectedPageIds,
|
||||||
|
dropSide,
|
||||||
hoveredItemId,
|
hoveredItemId,
|
||||||
finalDropSide,
|
|
||||||
visibleItems,
|
visibleItems,
|
||||||
filteredToOriginalIndex,
|
filteredToOriginalIndex,
|
||||||
items.length,
|
items,
|
||||||
overData ? overData.index : null
|
onReorderPages,
|
||||||
);
|
clearBoxSelection,
|
||||||
|
],
|
||||||
if (targetIndex === null) {
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (targetIndex < 0) targetIndex = 0;
|
|
||||||
if (targetIndex > items.length) targetIndex = items.length;
|
|
||||||
|
|
||||||
// Check if this page is box-selected
|
|
||||||
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
|
|
||||||
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
|
|
||||||
|
|
||||||
// Call reorder with page number and target index
|
|
||||||
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
|
|
||||||
|
|
||||||
// Highlight moved pages briefly
|
|
||||||
const movedIds = pagesToDrag ?? [active.id as string];
|
|
||||||
setJustMovedIds(movedIds);
|
|
||||||
if (highlightTimeoutRef.current) {
|
|
||||||
window.clearTimeout(highlightTimeoutRef.current);
|
|
||||||
}
|
|
||||||
highlightTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
setJustMovedIds([]);
|
|
||||||
highlightTimeoutRef.current = null;
|
|
||||||
}, 1200);
|
|
||||||
|
|
||||||
// Clear box selection after drag
|
|
||||||
if (pagesToDrag) {
|
|
||||||
clearBoxSelection();
|
|
||||||
}
|
|
||||||
}, [boxSelectedPageIds, dropSide, hoveredItemId, visibleItems, filteredToOriginalIndex, items, onReorderPages, clearBoxSelection]);
|
|
||||||
|
|
||||||
// Calculate optimal width for centering
|
// Calculate optimal width for centering
|
||||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
@@ -677,13 +732,16 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
||||||
|
|
||||||
// Calculate selection box dimensions
|
// Calculate selection box dimensions
|
||||||
const selectionBoxStyle = isBoxSelecting && boxSelectStart && boxSelectEnd ? {
|
const selectionBoxStyle =
|
||||||
left: Math.min(boxSelectStart.x, boxSelectEnd.x),
|
isBoxSelecting && boxSelectStart && boxSelectEnd
|
||||||
top: Math.min(boxSelectStart.y, boxSelectEnd.y),
|
? {
|
||||||
width: Math.abs(boxSelectEnd.x - boxSelectStart.x),
|
left: Math.min(boxSelectStart.x, boxSelectEnd.x),
|
||||||
height: Math.abs(boxSelectEnd.y - boxSelectStart.y),
|
top: Math.min(boxSelectStart.y, boxSelectEnd.y),
|
||||||
zIndex: Z_INDEX_SELECTION_BOX,
|
width: Math.abs(boxSelectEnd.x - boxSelectStart.x),
|
||||||
} : null;
|
height: Math.abs(boxSelectEnd.y - boxSelectStart.y),
|
||||||
|
zIndex: Z_INDEX_SELECTION_BOX,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
// Calculate drop indicator position
|
// Calculate drop indicator position
|
||||||
const dropIndicatorStyle = useMemo(() => {
|
const dropIndicatorStyle = useMemo(() => {
|
||||||
@@ -698,9 +756,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
|
|
||||||
const top = itemRect.top - containerRect.top;
|
const top = itemRect.top - containerRect.top;
|
||||||
const height = itemRect.height;
|
const height = itemRect.height;
|
||||||
const left = dropSide === 'left'
|
const left =
|
||||||
? itemRect.left - containerRect.left - itemGap / 2
|
dropSide === "left"
|
||||||
: itemRect.right - containerRect.left + itemGap / 2;
|
? itemRect.left - containerRect.left - itemGap / 2
|
||||||
|
: itemRect.right - containerRect.left + itemGap / 2;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: `${left}px`,
|
left: `${left}px`,
|
||||||
@@ -718,23 +777,26 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
return [activeId];
|
return [activeId];
|
||||||
}, [activeId, boxSelectedPageIds]);
|
}, [activeId, boxSelectedPageIds]);
|
||||||
|
|
||||||
const handleWheelWhileDragging = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
const handleWheelWhileDragging = useCallback(
|
||||||
if (!activeId) {
|
(event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
return;
|
if (!activeId) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const scrollElement = getScrollElement();
|
const scrollElement = getScrollElement();
|
||||||
if (!scrollElement) {
|
if (!scrollElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollElement.scrollBy({
|
scrollElement.scrollBy({
|
||||||
top: event.deltaY,
|
top: event.deltaY,
|
||||||
left: event.deltaX,
|
left: event.deltaX,
|
||||||
});
|
});
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}, [activeId, getScrollElement]);
|
},
|
||||||
|
[activeId, getScrollElement],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -752,26 +814,16 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onWheel={handleWheelWhileDragging}
|
onWheel={handleWheelWhileDragging}
|
||||||
>
|
>
|
||||||
{selectionBoxStyle && (
|
{selectionBoxStyle && <div className={styles.selectionBox} style={selectionBoxStyle} />}
|
||||||
<div
|
|
||||||
className={styles.selectionBox}
|
|
||||||
style={selectionBoxStyle}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dropIndicatorStyle && (
|
{dropIndicatorStyle && <div className={styles.dropIndicator} style={dropIndicatorStyle} />}
|
||||||
<div
|
|
||||||
className={styles.dropIndicator}
|
|
||||||
style={dropIndicatorStyle}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.virtualRows}
|
className={styles.virtualRows}
|
||||||
style={{
|
style={{
|
||||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
maxWidth: `${gridWidth}px`,
|
maxWidth: `${gridWidth}px`,
|
||||||
margin: '0 auto',
|
margin: "0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualRows.map((virtualRow) => {
|
{virtualRows.map((virtualRow) => {
|
||||||
@@ -826,10 +878,7 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
{activeId && (
|
{activeId && (
|
||||||
<div className={styles.dragOverlay}>
|
<div className={styles.dragOverlay}>
|
||||||
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && (
|
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && (
|
||||||
<div
|
<div className={styles.dragOverlayBadge} style={{ zIndex: Z_INDEX_DRAG_BADGE }}>
|
||||||
className={styles.dragOverlayBadge}
|
|
||||||
style={{ zIndex: Z_INDEX_DRAG_BADGE }}
|
|
||||||
>
|
|
||||||
{boxSelectedPageIds.length}
|
{boxSelectedPageIds.length}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -840,9 +889,9 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
style={{
|
style={{
|
||||||
width: `calc(20rem * ${zoomLevel})`,
|
width: `calc(20rem * ${zoomLevel})`,
|
||||||
height: `calc(20rem * ${zoomLevel})`,
|
height: `calc(20rem * ${zoomLevel})`,
|
||||||
objectFit: 'contain',
|
objectFit: "contain",
|
||||||
transform: `rotate(${dragPreview.rotation}deg)`,
|
transform: `rotate(${dragPreview.rotation}deg)`,
|
||||||
pointerEvents: 'none',
|
pointerEvents: "none",
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -852,11 +901,11 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
style={{
|
style={{
|
||||||
width: `calc(20rem * ${zoomLevel})`,
|
width: `calc(20rem * ${zoomLevel})`,
|
||||||
height: `calc(20rem * ${zoomLevel})`,
|
height: `calc(20rem * ${zoomLevel})`,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
fontSize: '2rem',
|
fontSize: "2rem",
|
||||||
color: 'var(--mantine-color-dimmed)',
|
color: "var(--mantine-color-dimmed)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="description" width="3rem" height="3rem" />
|
<LocalIcon icon="description" width="3rem" height="3rem" />
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
import React, { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
||||||
import { ActionIcon, CheckboxIndicator } from '@mantine/core';
|
import { ActionIcon, CheckboxIndicator } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
import PushPinIcon from "@mui/icons-material/PushPin";
|
||||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
||||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||||
|
|
||||||
import styles from '@app/components/pageEditor/PageEditor.module.css';
|
import styles from "@app/components/pageEditor/PageEditor.module.css";
|
||||||
import { useFileContext } from '@app/contexts/FileContext';
|
import { useFileContext } from "@app/contexts/FileContext";
|
||||||
import { FileId } from '@app/types/file';
|
import { FileId } from "@app/types/file";
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||||
import { downloadFile } from '@app/services/downloadService';
|
import { downloadFile } from "@app/services/downloadService";
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
id: FileId;
|
id: FileId;
|
||||||
@@ -66,13 +66,13 @@ const FileThumbnail = ({
|
|||||||
|
|
||||||
// Resolve the actual File object for pin/unpin operations
|
// Resolve the actual File object for pin/unpin operations
|
||||||
const actualFile = useMemo(() => {
|
const actualFile = useMemo(() => {
|
||||||
return activeFiles.find(f => f.fileId === file.id);
|
return activeFiles.find((f) => f.fileId === file.id);
|
||||||
}, [activeFiles, file.id]);
|
}, [activeFiles, file.id]);
|
||||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||||
|
|
||||||
const downloadSelectedFile = useCallback(() => {
|
const downloadSelectedFile = useCallback(() => {
|
||||||
// Prefer parent-provided handler if available
|
// Prefer parent-provided handler if available
|
||||||
if (typeof onDownloadFile === 'function') {
|
if (typeof onDownloadFile === "function") {
|
||||||
onDownloadFile(file.id);
|
onDownloadFile(file.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ const FileThumbnail = ({
|
|||||||
// Fallback: attempt to download using the File object if provided
|
// Fallback: attempt to download using the File object if provided
|
||||||
const maybeFile = (file as unknown as { file?: File }).file;
|
const maybeFile = (file as unknown as { file?: File }).file;
|
||||||
if (maybeFile instanceof File) {
|
if (maybeFile instanceof File) {
|
||||||
void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || 'download' });
|
void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || "download" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,52 +93,55 @@ const FileThumbnail = ({
|
|||||||
const isSelected = selectedFiles.includes(file.id);
|
const isSelected = selectedFiles.includes(file.id);
|
||||||
|
|
||||||
// ---- Drag & drop wiring ----
|
// ---- Drag & drop wiring ----
|
||||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
const fileElementRef = useCallback(
|
||||||
if (!element) return;
|
(element: HTMLDivElement | null) => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
dragElementRef.current = element;
|
dragElementRef.current = element;
|
||||||
|
|
||||||
const dragCleanup = draggable({
|
const dragCleanup = draggable({
|
||||||
element,
|
element,
|
||||||
getInitialData: () => ({
|
getInitialData: () => ({
|
||||||
type: 'file',
|
type: "file",
|
||||||
fileId: file.id,
|
fileId: file.id,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
selectedFiles: [file.id] // Always drag only this file, ignore selection state
|
selectedFiles: [file.id], // Always drag only this file, ignore selection state
|
||||||
}),
|
}),
|
||||||
onDragStart: () => {
|
onDragStart: () => {
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
},
|
},
|
||||||
onDrop: () => {
|
onDrop: () => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const dropCleanup = dropTargetForElements({
|
const dropCleanup = dropTargetForElements({
|
||||||
element,
|
element,
|
||||||
getData: () => ({
|
getData: () => ({
|
||||||
type: 'file',
|
type: "file",
|
||||||
fileId: file.id
|
fileId: file.id,
|
||||||
}),
|
}),
|
||||||
canDrop: ({ source }) => {
|
canDrop: ({ source }) => {
|
||||||
const sourceData = source.data;
|
const sourceData = source.data;
|
||||||
return sourceData.type === 'file' && sourceData.fileId !== file.id;
|
return sourceData.type === "file" && sourceData.fileId !== file.id;
|
||||||
},
|
},
|
||||||
onDrop: ({ source }) => {
|
onDrop: ({ source }) => {
|
||||||
const sourceData = source.data;
|
const sourceData = source.data;
|
||||||
if (sourceData.type === 'file' && onReorderFiles) {
|
if (sourceData.type === "file" && onReorderFiles) {
|
||||||
const sourceFileId = sourceData.fileId as FileId;
|
const sourceFileId = sourceData.fileId as FileId;
|
||||||
const selectedFileIds = sourceData.selectedFiles as FileId[];
|
const selectedFileIds = sourceData.selectedFiles as FileId[];
|
||||||
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
dragCleanup();
|
dragCleanup();
|
||||||
dropCleanup();
|
dropCleanup();
|
||||||
};
|
};
|
||||||
}, [file.id, file.name, selectedFiles, onReorderFiles]);
|
},
|
||||||
|
[file.id, file.name, selectedFiles, onReorderFiles],
|
||||||
|
);
|
||||||
|
|
||||||
// Update dropdown width on resize
|
// Update dropdown width on resize
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,8 +149,8 @@ const FileThumbnail = ({
|
|||||||
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
|
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
|
||||||
};
|
};
|
||||||
update();
|
update();
|
||||||
window.addEventListener('resize', update);
|
window.addEventListener("resize", update);
|
||||||
return () => window.removeEventListener('resize', update);
|
return () => window.removeEventListener("resize", update);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Close the actions dropdown when hovering outside this file card (and its dropdown)
|
// Close the actions dropdown when hovering outside this file card (and its dropdown)
|
||||||
@@ -173,11 +176,11 @@ const FileThumbnail = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
document.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener('touchstart', handleTouchStart);
|
document.removeEventListener("touchstart", handleTouchStart);
|
||||||
};
|
};
|
||||||
}, [showActions]);
|
}, [showActions]);
|
||||||
|
|
||||||
@@ -187,7 +190,6 @@ const FileThumbnail = ({
|
|||||||
onToggleFile(file.id);
|
onToggleFile(file.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={fileElementRef}
|
ref={fileElementRef}
|
||||||
@@ -198,7 +200,7 @@ const FileThumbnail = ({
|
|||||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||||
style={{
|
style={{
|
||||||
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
|
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
|
||||||
filter: isSupported ? 'none' : 'grayscale(50%)',
|
filter: isSupported ? "none" : "grayscale(50%)",
|
||||||
}}
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
@@ -206,11 +208,7 @@ const FileThumbnail = ({
|
|||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
>
|
>
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div
|
<div className={`${styles.header} ${isSelected ? styles.headerSelected : styles.headerResting}`}>
|
||||||
className={`${styles.header} ${
|
|
||||||
isSelected ? styles.headerSelected : styles.headerResting
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Logo/checkbox area */}
|
{/* Logo/checkbox area */}
|
||||||
<div className={styles.logoMark}>
|
<div className={styles.logoMark}>
|
||||||
{isSupported ? (
|
{isSupported ? (
|
||||||
@@ -221,9 +219,7 @@ const FileThumbnail = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.unsupportedPill}>
|
<div className={styles.unsupportedPill}>
|
||||||
<span>
|
<span>{t("unsupported", "Unsupported")}</span>
|
||||||
{t('unsupported', 'Unsupported')}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -235,7 +231,7 @@ const FileThumbnail = ({
|
|||||||
|
|
||||||
{/* Kebab menu */}
|
{/* Kebab menu */}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
aria-label={t('moreOptions', 'More options')}
|
aria-label={t("moreOptions", "More options")}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
className={styles.kebab}
|
className={styles.kebab}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -249,11 +245,7 @@ const FileThumbnail = ({
|
|||||||
|
|
||||||
{/* Actions overlay */}
|
{/* Actions overlay */}
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<div
|
<div className={styles.actionsOverlay} style={{ width: actionsWidth }} onClick={(e) => e.stopPropagation()}>
|
||||||
className={styles.actionsOverlay}
|
|
||||||
style={{ width: actionsWidth }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
className={styles.actionRow}
|
className={styles.actionRow}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -270,12 +262,15 @@ const FileThumbnail = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||||
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
|
<span>{isPinned ? t("unpin", "Unpin") : t("pin", "Pin")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={styles.actionRow}
|
className={styles.actionRow}
|
||||||
onClick={() => { downloadSelectedFile(); setShowActions(false); }}
|
onClick={() => {
|
||||||
|
downloadSelectedFile();
|
||||||
|
setShowActions(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DownloadOutlinedIcon fontSize="small" />
|
<DownloadOutlinedIcon fontSize="small" />
|
||||||
<span>{terminology.download}</span>
|
<span>{terminology.download}</span>
|
||||||
@@ -292,7 +287,7 @@ const FileThumbnail = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
<span>{t('delete', 'Delete')}</span>
|
<span>{t("delete", "Delete")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -302,17 +297,17 @@ const FileThumbnail = ({
|
|||||||
{/* Stacked file effect - multiple shadows to simulate pages */}
|
{/* Stacked file effect - multiple shadows to simulate pages */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: "100%",
|
||||||
height: '100%',
|
height: "100%",
|
||||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
backgroundColor: "var(--mantine-color-gray-1)",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
border: '1px solid var(--mantine-color-gray-3)',
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
padding: 4,
|
padding: 4,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
position: 'relative',
|
position: "relative",
|
||||||
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
|
boxShadow: "2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{file.thumbnail && (
|
{file.thumbnail && (
|
||||||
@@ -324,21 +319,21 @@ const FileThumbnail = ({
|
|||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// Hide broken image if blob URL was revoked
|
// Hide broken image if blob URL was revoked
|
||||||
const img = e.target as HTMLImageElement;
|
const img = e.target as HTMLImageElement;
|
||||||
img.style.display = 'none';
|
img.style.display = "none";
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '80%',
|
maxWidth: "80%",
|
||||||
maxHeight: '80%',
|
maxHeight: "80%",
|
||||||
objectFit: 'contain',
|
objectFit: "contain",
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
background: '#ffffff',
|
background: "#ffffff",
|
||||||
border: '1px solid var(--border-default)',
|
border: "1px solid var(--border-default)",
|
||||||
display: 'block',
|
display: "block",
|
||||||
marginLeft: 'auto',
|
marginLeft: "auto",
|
||||||
marginRight: 'auto',
|
marginRight: "auto",
|
||||||
alignSelf: 'start'
|
alignSelf: "start",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PrivateContent>
|
</PrivateContent>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,7 +52,8 @@
|
|||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
|
|
||||||
.unsupportedPill {
|
.unsupportedPill {
|
||||||
margin-left: 1.75rem;
|
margin-left: 1.75rem;
|
||||||
background: #6B7280;
|
background: #6b7280;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import { useNavigationGuard, useNavigationState } from "@app/contexts/Navigation
|
|||||||
import { usePageEditor } from "@app/contexts/PageEditorContext";
|
import { usePageEditor } from "@app/contexts/PageEditorContext";
|
||||||
import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor";
|
import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor";
|
||||||
// Thumbnail generation is now handled by individual PageThumbnail components
|
// Thumbnail generation is now handled by individual PageThumbnail components
|
||||||
import '@app/components/pageEditor/PageEditor.module.css';
|
import "@app/components/pageEditor/PageEditor.module.css";
|
||||||
import PageThumbnail from '@app/components/pageEditor/PageThumbnail';
|
import PageThumbnail from "@app/components/pageEditor/PageThumbnail";
|
||||||
import DragDropGrid from '@app/components/pageEditor/DragDropGrid';
|
import DragDropGrid from "@app/components/pageEditor/DragDropGrid";
|
||||||
import SkeletonLoader from '@app/components/shared/SkeletonLoader';
|
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
|
||||||
import { FileId } from "@app/types/file";
|
import { FileId } from "@app/types/file";
|
||||||
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
|
import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
|
||||||
import { useInitialPageDocument } from '@app/components/pageEditor/hooks/useInitialPageDocument';
|
import { useInitialPageDocument } from "@app/components/pageEditor/hooks/useInitialPageDocument";
|
||||||
import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument';
|
import { usePageDocument } from "@app/components/pageEditor/hooks/usePageDocument";
|
||||||
import { usePageEditorState } from '@app/components/pageEditor/hooks/usePageEditorState';
|
import { usePageEditorState } from "@app/components/pageEditor/hooks/usePageEditorState";
|
||||||
import { usePageEditorRightRailButtons } from "@app/components/pageEditor/pageEditorRightRailButtons";
|
import { usePageEditorRightRailButtons } from "@app/components/pageEditor/pageEditorRightRailButtons";
|
||||||
import { useFileColorMap } from "@app/components/pageEditor/hooks/useFileColorMap";
|
import { useFileColorMap } from "@app/components/pageEditor/hooks/useFileColorMap";
|
||||||
import { useWheelZoom } from "@app/hooks/useWheelZoom";
|
import { useWheelZoom } from "@app/hooks/useWheelZoom";
|
||||||
@@ -23,22 +23,20 @@ import { usePageSelectionManager } from "@app/components/pageEditor/hooks/usePag
|
|||||||
import { usePageEditorCommands } from "@app/components/pageEditor/hooks/useEditorCommands";
|
import { usePageEditorCommands } from "@app/components/pageEditor/hooks/useEditorCommands";
|
||||||
import { usePageEditorExport } from "@app/components/pageEditor/hooks/usePageEditorExport";
|
import { usePageEditorExport } from "@app/components/pageEditor/hooks/usePageEditorExport";
|
||||||
import { useThumbnailGeneration } from "@app/hooks/useThumbnailGeneration";
|
import { useThumbnailGeneration } from "@app/hooks/useThumbnailGeneration";
|
||||||
import { convertSplitPageIdsToIndexes } from '@app/components/pageEditor/utils/splitPositions';
|
import { convertSplitPageIdsToIndexes } from "@app/components/pageEditor/utils/splitPositions";
|
||||||
|
|
||||||
export interface PageEditorProps {
|
export interface PageEditorProps {
|
||||||
onFunctionsReady?: (functions: PageEditorFunctions) => void;
|
onFunctionsReady?: (functions: PageEditorFunctions) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageEditor = ({
|
const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||||
onFunctionsReady,
|
|
||||||
}: PageEditorProps) => {
|
|
||||||
|
|
||||||
// Use split contexts to prevent re-renders
|
// Use split contexts to prevent re-renders
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
const { actions } = useFileActions();
|
const { actions } = useFileActions();
|
||||||
|
|
||||||
// Navigation guard for unsaved changes
|
// Navigation guard for unsaved changes
|
||||||
const { setHasUnsavedChanges, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } = useNavigationGuard();
|
const { setHasUnsavedChanges, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } =
|
||||||
|
useNavigationGuard();
|
||||||
const navigationState = useNavigationState();
|
const navigationState = useNavigationState();
|
||||||
|
|
||||||
// Get PageEditor coordination functions
|
// Get PageEditor coordination functions
|
||||||
@@ -56,8 +54,8 @@ const PageEditor = ({
|
|||||||
const thumbnailRequestsRef = useRef<Set<string>>(new Set());
|
const thumbnailRequestsRef = useRef<Set<string>>(new Set());
|
||||||
const { requestThumbnail, getThumbnailFromCache } = useThumbnailGeneration();
|
const { requestThumbnail, getThumbnailFromCache } = useThumbnailGeneration();
|
||||||
const handleVisibleItemsChange = useCallback((items: PDFPage[]) => {
|
const handleVisibleItemsChange = useCallback((items: PDFPage[]) => {
|
||||||
setVisiblePageIds(prev => {
|
setVisiblePageIds((prev) => {
|
||||||
const ids = items.map(item => item.id);
|
const ids = items.map((item) => item.id);
|
||||||
if (prev.length === ids.length && prev.every((id, index) => id === ids[index])) {
|
if (prev.length === ids.length && prev.every((id, index) => id === ids[index])) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
@@ -70,7 +68,7 @@ const PageEditor = ({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isContainerHovered, setIsContainerHovered] = useState(false);
|
const [isContainerHovered, setIsContainerHovered] = useState(false);
|
||||||
const rootFontSize = useMemo(() => {
|
const rootFontSize = useMemo(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === "undefined") {
|
||||||
return 16;
|
return 16;
|
||||||
}
|
}
|
||||||
const computed = getComputedStyle(document.documentElement).fontSize;
|
const computed = getComputedStyle(document.documentElement).fontSize;
|
||||||
@@ -83,19 +81,19 @@ const PageEditor = ({
|
|||||||
|
|
||||||
// Zoom actions
|
// Zoom actions
|
||||||
const zoomIn = useCallback(() => {
|
const zoomIn = useCallback(() => {
|
||||||
setZoomLevel(prev => Math.min(prev + 0.1, 3.0));
|
setZoomLevel((prev) => Math.min(prev + 0.1, 3.0));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const zoomOut = useCallback(() => {
|
const zoomOut = useCallback(() => {
|
||||||
setZoomLevel(prev => Math.max(prev - 0.1, 0.5));
|
setZoomLevel((prev) => Math.max(prev - 0.1, 0.5));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Derive page editor files from PageEditorContext's fileOrder (page editor workspace order)
|
// Derive page editor files from PageEditorContext's fileOrder (page editor workspace order)
|
||||||
// Filter to only show PDF files (PageEditor only supports PDFs)
|
// Filter to only show PDF files (PageEditor only supports PDFs)
|
||||||
// Use stable string keys to prevent infinite loops
|
// Use stable string keys to prevent infinite loops
|
||||||
// Cache file objects to prevent infinite re-renders from new object references
|
// Cache file objects to prevent infinite re-renders from new object references
|
||||||
const fileOrderKey = fileOrder.join(',');
|
const fileOrderKey = fileOrder.join(",");
|
||||||
const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(',');
|
const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(",");
|
||||||
const filesSignature = selectors.getFilesSignature();
|
const filesSignature = selectors.getFilesSignature();
|
||||||
|
|
||||||
const fileObjectsRef = useRef(new Map<FileId, any>());
|
const fileObjectsRef = useRef(new Map<FileId, any>());
|
||||||
@@ -105,28 +103,30 @@ const PageEditor = ({
|
|||||||
const cache = fileObjectsRef.current;
|
const cache = fileObjectsRef.current;
|
||||||
const newFiles: any[] = [];
|
const newFiles: any[] = [];
|
||||||
|
|
||||||
fileOrder.forEach(fileId => {
|
fileOrder.forEach((fileId) => {
|
||||||
const stub = selectors.getStirlingFileStub(fileId);
|
const stub = selectors.getStirlingFileStub(fileId);
|
||||||
const isSelected = state.ui.selectedFileIds.includes(fileId);
|
const isSelected = state.ui.selectedFileIds.includes(fileId);
|
||||||
const isPdf = stub?.name?.toLowerCase().endsWith('.pdf') ?? false;
|
const isPdf = stub?.name?.toLowerCase().endsWith(".pdf") ?? false;
|
||||||
|
|
||||||
if (!isPdf) return; // Skip non-PDFs
|
if (!isPdf) return; // Skip non-PDFs
|
||||||
|
|
||||||
const cached = cache.get(fileId);
|
const cached = cache.get(fileId);
|
||||||
|
|
||||||
// Check if data actually changed (compare by fileId, not position)
|
// Check if data actually changed (compare by fileId, not position)
|
||||||
if (cached &&
|
if (
|
||||||
cached.fileId === fileId &&
|
cached &&
|
||||||
cached.name === (stub?.name || '') &&
|
cached.fileId === fileId &&
|
||||||
cached.versionNumber === stub?.versionNumber &&
|
cached.name === (stub?.name || "") &&
|
||||||
cached.isSelected === isSelected) {
|
cached.versionNumber === stub?.versionNumber &&
|
||||||
|
cached.isSelected === isSelected
|
||||||
|
) {
|
||||||
// Reuse existing object reference
|
// Reuse existing object reference
|
||||||
newFiles.push(cached);
|
newFiles.push(cached);
|
||||||
} else {
|
} else {
|
||||||
// Create new object only if data changed
|
// Create new object only if data changed
|
||||||
const newFile = {
|
const newFile = {
|
||||||
fileId,
|
fileId,
|
||||||
name: stub?.name || '',
|
name: stub?.name || "",
|
||||||
versionNumber: stub?.versionNumber,
|
versionNumber: stub?.versionNumber,
|
||||||
isSelected,
|
isSelected,
|
||||||
};
|
};
|
||||||
@@ -136,7 +136,7 @@ const PageEditor = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Clean up removed files from cache
|
// Clean up removed files from cache
|
||||||
const activeIds = new Set(newFiles.map(f => f.fileId));
|
const activeIds = new Set(newFiles.map((f) => f.fileId));
|
||||||
for (const cachedId of cache.keys()) {
|
for (const cachedId of cache.keys()) {
|
||||||
if (!activeIds.has(cachedId)) {
|
if (!activeIds.has(cachedId)) {
|
||||||
cache.delete(cachedId);
|
cache.delete(cachedId);
|
||||||
@@ -148,12 +148,12 @@ const PageEditor = ({
|
|||||||
|
|
||||||
// Get ALL file IDs in order (not filtered by selection)
|
// Get ALL file IDs in order (not filtered by selection)
|
||||||
const orderedFileIds = useMemo(() => {
|
const orderedFileIds = useMemo(() => {
|
||||||
return pageEditorFiles.map(f => f.fileId);
|
return pageEditorFiles.map((f) => f.fileId);
|
||||||
}, [pageEditorFiles]);
|
}, [pageEditorFiles]);
|
||||||
|
|
||||||
// Get selected file IDs for filtering
|
// Get selected file IDs for filtering
|
||||||
const selectedFileIds = useMemo(() => {
|
const selectedFileIds = useMemo(() => {
|
||||||
return pageEditorFiles.filter(f => f.isSelected).map(f => f.fileId);
|
return pageEditorFiles.filter((f) => f.isSelected).map((f) => f.fileId);
|
||||||
}, [pageEditorFiles]);
|
}, [pageEditorFiles]);
|
||||||
const activeFilesSignature = selectors.getFilesSignature();
|
const activeFilesSignature = selectors.getFilesSignature();
|
||||||
|
|
||||||
@@ -177,88 +177,83 @@ const PageEditor = ({
|
|||||||
displayDocumentRef.current = displayDocument;
|
displayDocumentRef.current = displayDocument;
|
||||||
}, [displayDocument]);
|
}, [displayDocument]);
|
||||||
|
|
||||||
const queueThumbnailRequestsForPages = useCallback((pageIds: string[]) => {
|
const queueThumbnailRequestsForPages = useCallback(
|
||||||
const doc = displayDocumentRef.current;
|
(pageIds: string[]) => {
|
||||||
if (!doc || pageIds.length === 0) return;
|
const doc = displayDocumentRef.current;
|
||||||
|
if (!doc || pageIds.length === 0) return;
|
||||||
|
|
||||||
const loadedCount = doc.pages.filter(p => p.thumbnail).length;
|
const loadedCount = doc.pages.filter((p) => p.thumbnail).length;
|
||||||
const pending = thumbnailRequestsRef.current.size;
|
const pending = thumbnailRequestsRef.current.size;
|
||||||
const MAX_CONCURRENT_THUMBNAILS = loadedCount < 8 ? 1
|
const MAX_CONCURRENT_THUMBNAILS = loadedCount < 8 ? 1 : doc.totalPages < 20 ? 3 : doc.totalPages < 50 ? 5 : 8;
|
||||||
: doc.totalPages < 20 ? 3
|
const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending);
|
||||||
: doc.totalPages < 50 ? 5
|
if (available === 0) return;
|
||||||
: 8;
|
|
||||||
const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending);
|
|
||||||
if (available === 0) return;
|
|
||||||
|
|
||||||
const toLoad: string[] = [];
|
const toLoad: string[] = [];
|
||||||
for (const pageId of pageIds) {
|
for (const pageId of pageIds) {
|
||||||
if (toLoad.length >= available) break;
|
if (toLoad.length >= available) break;
|
||||||
if (thumbnailRequestsRef.current.has(pageId)) continue;
|
if (thumbnailRequestsRef.current.has(pageId)) continue;
|
||||||
const page = doc.pages.find(p => p.id === pageId);
|
const page = doc.pages.find((p) => p.id === pageId);
|
||||||
if (!page || page.thumbnail) continue;
|
if (!page || page.thumbnail) continue;
|
||||||
toLoad.push(pageId);
|
toLoad.push(pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toLoad.length === 0) return;
|
if (toLoad.length === 0) return;
|
||||||
|
|
||||||
toLoad.forEach(pageId => {
|
toLoad.forEach((pageId) => {
|
||||||
const page = doc.pages.find(p => p.id === pageId);
|
const page = doc.pages.find((p) => p.id === pageId);
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
|
|
||||||
const cached = getThumbnailFromCache(pageId);
|
const cached = getThumbnailFromCache(pageId);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
thumbnailRequestsRef.current.add(pageId);
|
thumbnailRequestsRef.current.add(pageId);
|
||||||
Promise.resolve(cached)
|
Promise.resolve(cached)
|
||||||
.then(cache => {
|
.then((cache) => {
|
||||||
setEditedDocument(prev => {
|
setEditedDocument((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
const pageIndex = prev.pages.findIndex(p => p.id === pageId);
|
const pageIndex = prev.pages.findIndex((p) => p.id === pageId);
|
||||||
if (pageIndex === -1) return prev;
|
if (pageIndex === -1) return prev;
|
||||||
|
|
||||||
const updated = [...prev.pages];
|
const updated = [...prev.pages];
|
||||||
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail: cache };
|
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail: cache };
|
||||||
return { ...prev, pages: updated };
|
return { ...prev, pages: updated };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
thumbnailRequestsRef.current.delete(pageId);
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = page.originalFileId;
|
||||||
|
if (!fileId) return;
|
||||||
|
const file = selectors.getFile(fileId);
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
thumbnailRequestsRef.current.add(pageId);
|
||||||
|
requestThumbnail(pageId, file, page.originalPageNumber || page.pageNumber)
|
||||||
|
.then((thumbnail) => {
|
||||||
|
if (thumbnail) {
|
||||||
|
setEditedDocument((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const pageIndex = prev.pages.findIndex((p) => p.id === pageId);
|
||||||
|
if (pageIndex === -1) return prev;
|
||||||
|
|
||||||
|
const updated = [...prev.pages];
|
||||||
|
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail };
|
||||||
|
return { ...prev, pages: updated };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[Thumbnail Loading] Error:", error);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
thumbnailRequestsRef.current.delete(pageId);
|
thumbnailRequestsRef.current.delete(pageId);
|
||||||
});
|
});
|
||||||
return;
|
});
|
||||||
}
|
},
|
||||||
|
[getThumbnailFromCache, requestThumbnail, selectors, setEditedDocument],
|
||||||
const fileId = page.originalFileId;
|
);
|
||||||
if (!fileId) return;
|
|
||||||
const file = selectors.getFile(fileId);
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
thumbnailRequestsRef.current.add(pageId);
|
|
||||||
requestThumbnail(pageId, file, page.originalPageNumber || page.pageNumber)
|
|
||||||
.then(thumbnail => {
|
|
||||||
if (thumbnail) {
|
|
||||||
setEditedDocument(prev => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
const pageIndex = prev.pages.findIndex(p => p.id === pageId);
|
|
||||||
if (pageIndex === -1) return prev;
|
|
||||||
|
|
||||||
const updated = [...prev.pages];
|
|
||||||
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail };
|
|
||||||
return { ...prev, pages: updated };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('[Thumbnail Loading] Error:', error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
thumbnailRequestsRef.current.delete(pageId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
getThumbnailFromCache,
|
|
||||||
requestThumbnail,
|
|
||||||
selectors,
|
|
||||||
setEditedDocument
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!displayDocument) {
|
if (!displayDocument) {
|
||||||
@@ -282,9 +277,7 @@ const PageEditor = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_VISIBLE_PAGE_COUNT = 8;
|
const INITIAL_VISIBLE_PAGE_COUNT = 8;
|
||||||
const initialIds = displayDocument.pages
|
const initialIds = displayDocument.pages.slice(0, INITIAL_VISIBLE_PAGE_COUNT).map((page) => page.id);
|
||||||
.slice(0, INITIAL_VISIBLE_PAGE_COUNT)
|
|
||||||
.map(page => page.id);
|
|
||||||
|
|
||||||
queueThumbnailRequestsForPages(initialIds);
|
queueThumbnailRequestsForPages(initialIds);
|
||||||
lastInitialDocumentSignatureRef.current = signature;
|
lastInitialDocumentSignatureRef.current = signature;
|
||||||
@@ -296,13 +289,13 @@ const PageEditor = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (navigationState.workbench !== 'pageEditor') {
|
if (navigationState.workbench !== "pageEditor") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = displayDocumentRef.current;
|
const doc = displayDocumentRef.current;
|
||||||
if (doc && doc.pages.length > 0) {
|
if (doc && doc.pages.length > 0) {
|
||||||
const signature = doc.pages.map(page => page.id).join(',');
|
const signature = doc.pages.map((page) => page.id).join(",");
|
||||||
savePersistedDocument(doc, signature);
|
savePersistedDocument(doc, signature);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -310,9 +303,20 @@ const PageEditor = ({
|
|||||||
|
|
||||||
// UI state management
|
// UI state management
|
||||||
const {
|
const {
|
||||||
selectionMode, selectedPageIds, movingPage, isAnimating, splitPositions, exportLoading,
|
selectionMode,
|
||||||
setSelectionMode, setSelectedPageIds, setMovingPage, setSplitPositions, setExportLoading,
|
selectedPageIds,
|
||||||
togglePage, toggleSelectAll, animateReorder
|
movingPage,
|
||||||
|
isAnimating,
|
||||||
|
splitPositions,
|
||||||
|
exportLoading,
|
||||||
|
setSelectionMode,
|
||||||
|
setSelectedPageIds,
|
||||||
|
setMovingPage,
|
||||||
|
setSplitPositions,
|
||||||
|
setExportLoading,
|
||||||
|
togglePage,
|
||||||
|
toggleSelectAll,
|
||||||
|
animateReorder,
|
||||||
} = usePageEditorState();
|
} = usePageEditorState();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -336,14 +340,9 @@ const PageEditor = ({
|
|||||||
// Grid container ref for positioning split indicators
|
// Grid container ref for positioning split indicators
|
||||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const {
|
const { canUndo, canRedo, executeCommandWithTracking, handleUndo, handleRedo, clearUndoHistory } = useUndoManagerState({
|
||||||
canUndo,
|
setHasUnsavedChanges,
|
||||||
canRedo,
|
});
|
||||||
executeCommandWithTracking,
|
|
||||||
handleUndo,
|
|
||||||
handleRedo,
|
|
||||||
clearUndoHistory,
|
|
||||||
} = useUndoManagerState({ setHasUnsavedChanges });
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
createRotateCommand,
|
createRotateCommand,
|
||||||
@@ -428,17 +427,20 @@ const PageEditor = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Export preview function - defined after export functions to avoid circular dependency
|
// Export preview function - defined after export functions to avoid circular dependency
|
||||||
const handleExportPreview = useCallback((selectedOnly: boolean = false) => {
|
const handleExportPreview = useCallback(
|
||||||
if (!displayDocument) return;
|
(selectedOnly: boolean = false) => {
|
||||||
|
if (!displayDocument) return;
|
||||||
|
|
||||||
// For now, trigger the actual export directly
|
// For now, trigger the actual export directly
|
||||||
// In the original, this would show a preview modal first
|
// In the original, this would show a preview modal first
|
||||||
if (selectedOnly) {
|
if (selectedOnly) {
|
||||||
onExportSelected();
|
onExportSelected();
|
||||||
} else {
|
} else {
|
||||||
onExportAll();
|
onExportAll();
|
||||||
}
|
}
|
||||||
}, [displayDocument, onExportSelected, onExportAll]);
|
},
|
||||||
|
[displayDocument, onExportSelected, onExportAll],
|
||||||
|
);
|
||||||
|
|
||||||
// Expose functions to parent component
|
// Expose functions to parent component
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -471,9 +473,30 @@ const PageEditor = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
onFunctionsReady, handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, handleSplitAll,
|
onFunctionsReady,
|
||||||
handlePageBreak, handlePageBreakAll, handleSelectAll, handleDeselectAll, handleSetSelectedPages, handleExportPreview, onExportSelected, onExportAll, applyChanges, exportLoading,
|
handleUndo,
|
||||||
selectionMode, selectedPageIds, splitPositions, displayDocument?.pages.length, closePdf
|
handleRedo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
handleRotate,
|
||||||
|
handleDelete,
|
||||||
|
handleSplit,
|
||||||
|
handleSplitAll,
|
||||||
|
handlePageBreak,
|
||||||
|
handlePageBreakAll,
|
||||||
|
handleSelectAll,
|
||||||
|
handleDeselectAll,
|
||||||
|
handleSetSelectedPages,
|
||||||
|
handleExportPreview,
|
||||||
|
onExportSelected,
|
||||||
|
onExportAll,
|
||||||
|
applyChanges,
|
||||||
|
exportLoading,
|
||||||
|
selectionMode,
|
||||||
|
selectedPageIds,
|
||||||
|
splitPositions,
|
||||||
|
displayDocument?.pages.length,
|
||||||
|
closePdf,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useWheelZoom({
|
useWheelZoom({
|
||||||
@@ -490,15 +513,15 @@ const PageEditor = ({
|
|||||||
|
|
||||||
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
|
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
|
||||||
if (event.ctrlKey || event.metaKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
if (event.key === '=' || event.key === '+') {
|
if (event.key === "=" || event.key === "+") {
|
||||||
// Ctrl+= or Ctrl++ for zoom in
|
// Ctrl+= or Ctrl++ for zoom in
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
zoomIn();
|
zoomIn();
|
||||||
} else if (event.key === '-' || event.key === '_') {
|
} else if (event.key === "-" || event.key === "_") {
|
||||||
// Ctrl+- for zoom out
|
// Ctrl+- for zoom out
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
zoomOut();
|
zoomOut();
|
||||||
} else if (event.key === '0') {
|
} else if (event.key === "0") {
|
||||||
// Ctrl+0 for reset zoom
|
// Ctrl+0 for reset zoom
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setZoomLevel(1.0);
|
setZoomLevel(1.0);
|
||||||
@@ -506,9 +529,9 @@ const PageEditor = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [isContainerHovered, zoomIn, zoomOut]);
|
}, [isContainerHovered, zoomIn, zoomOut]);
|
||||||
|
|
||||||
@@ -520,75 +543,78 @@ const PageEditor = ({
|
|||||||
|
|
||||||
// Memoize renderItem to prevent DragDropGrid's React.memo from blocking updates
|
// Memoize renderItem to prevent DragDropGrid's React.memo from blocking updates
|
||||||
// when selectedPageIds changes
|
// when selectedPageIds changes
|
||||||
const renderItemCallback = useCallback((
|
const renderItemCallback = useCallback(
|
||||||
page: PDFPage,
|
(
|
||||||
index: number,
|
page: PDFPage,
|
||||||
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
|
index: number,
|
||||||
boxSelectedIds: string[],
|
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
|
||||||
clearBoxSelection: () => void,
|
boxSelectedIds: string[],
|
||||||
activeDragIds: string[],
|
clearBoxSelection: () => void,
|
||||||
justMoved: boolean,
|
activeDragIds: string[],
|
||||||
dragHandleProps?: any,
|
justMoved: boolean,
|
||||||
zoomLevelParam?: number
|
dragHandleProps?: any,
|
||||||
) => {
|
zoomLevelParam?: number,
|
||||||
gridItemRefsRef.current = refs;
|
) => {
|
||||||
const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0;
|
gridItemRefsRef.current = refs;
|
||||||
const isBoxSelected = boxSelectedIds.includes(page.id);
|
const fileColorIndex = page.originalFileId ? (fileColorIndexMap.get(page.originalFileId) ?? 0) : 0;
|
||||||
return (
|
const isBoxSelected = boxSelectedIds.includes(page.id);
|
||||||
<PageThumbnail
|
return (
|
||||||
key={page.id}
|
<PageThumbnail
|
||||||
page={page}
|
key={page.id}
|
||||||
index={index}
|
page={page}
|
||||||
totalPages={displayDocument?.pages.length || 0}
|
index={index}
|
||||||
fileColorIndex={fileColorIndex}
|
totalPages={displayDocument?.pages.length || 0}
|
||||||
selectedPageIds={selectedPageIds}
|
fileColorIndex={fileColorIndex}
|
||||||
selectionMode={selectionMode}
|
selectedPageIds={selectedPageIds}
|
||||||
movingPage={movingPage}
|
selectionMode={selectionMode}
|
||||||
isAnimating={isAnimating}
|
movingPage={movingPage}
|
||||||
isBoxSelected={isBoxSelected}
|
isAnimating={isAnimating}
|
||||||
clearBoxSelection={clearBoxSelection}
|
isBoxSelected={isBoxSelected}
|
||||||
activeDragIds={activeDragIds}
|
clearBoxSelection={clearBoxSelection}
|
||||||
justMoved={justMoved}
|
activeDragIds={activeDragIds}
|
||||||
pageRefs={refs}
|
justMoved={justMoved}
|
||||||
dragHandleProps={dragHandleProps}
|
pageRefs={refs}
|
||||||
onReorderPages={handleReorderPages}
|
dragHandleProps={dragHandleProps}
|
||||||
onTogglePage={togglePage}
|
onReorderPages={handleReorderPages}
|
||||||
onAnimateReorder={animateReorder}
|
onTogglePage={togglePage}
|
||||||
onExecuteCommand={executeCommand}
|
onAnimateReorder={animateReorder}
|
||||||
onSetStatus={() => {}}
|
onExecuteCommand={executeCommand}
|
||||||
onSetMovingPage={setMovingPage}
|
onSetStatus={() => {}}
|
||||||
onDeletePage={handleDeletePage}
|
onSetMovingPage={setMovingPage}
|
||||||
createRotateCommand={createRotateCommand}
|
onDeletePage={handleDeletePage}
|
||||||
createDeleteCommand={createDeleteCommand}
|
createRotateCommand={createRotateCommand}
|
||||||
createSplitCommand={createSplitCommand}
|
createDeleteCommand={createDeleteCommand}
|
||||||
pdfDocument={displayDocument!}
|
createSplitCommand={createSplitCommand}
|
||||||
setPdfDocument={setEditedDocument}
|
pdfDocument={displayDocument!}
|
||||||
splitPositions={splitPositions}
|
setPdfDocument={setEditedDocument}
|
||||||
onInsertFiles={handleInsertFiles}
|
splitPositions={splitPositions}
|
||||||
zoomLevel={zoomLevelParam || zoomLevel}
|
onInsertFiles={handleInsertFiles}
|
||||||
/>
|
zoomLevel={zoomLevelParam || zoomLevel}
|
||||||
);
|
/>
|
||||||
}, [
|
);
|
||||||
selectedPageIds,
|
},
|
||||||
selectionMode,
|
[
|
||||||
movingPage,
|
selectedPageIds,
|
||||||
isAnimating,
|
selectionMode,
|
||||||
displayDocument,
|
movingPage,
|
||||||
fileColorIndexMap,
|
isAnimating,
|
||||||
handleReorderPages,
|
displayDocument,
|
||||||
togglePage,
|
fileColorIndexMap,
|
||||||
animateReorder,
|
handleReorderPages,
|
||||||
executeCommand,
|
togglePage,
|
||||||
setMovingPage,
|
animateReorder,
|
||||||
handleDeletePage,
|
executeCommand,
|
||||||
createRotateCommand,
|
setMovingPage,
|
||||||
createDeleteCommand,
|
handleDeletePage,
|
||||||
createSplitCommand,
|
createRotateCommand,
|
||||||
setEditedDocument,
|
createDeleteCommand,
|
||||||
splitPositions,
|
createSplitCommand,
|
||||||
handleInsertFiles,
|
setEditedDocument,
|
||||||
zoomLevel,
|
splitPositions,
|
||||||
]);
|
handleInsertFiles,
|
||||||
|
zoomLevel,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -597,20 +623,24 @@ const PageEditor = ({
|
|||||||
onMouseEnter={() => setIsContainerHovered(true)}
|
onMouseEnter={() => setIsContainerHovered(true)}
|
||||||
onMouseLeave={() => setIsContainerHovered(false)}
|
onMouseLeave={() => setIsContainerHovered(false)}
|
||||||
style={{
|
style={{
|
||||||
height: '100%',
|
height: "100%",
|
||||||
overflow: 'auto',
|
overflow: "auto",
|
||||||
position: 'relative',
|
position: "relative",
|
||||||
width: '100%',
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LoadingOverlay visible={globalProcessing && !initialDocument} />
|
<LoadingOverlay visible={globalProcessing && !initialDocument} />
|
||||||
|
|
||||||
{!initialDocument && !globalProcessing && selectedFileIds.length === 0 && (
|
{!initialDocument && !globalProcessing && selectedFileIds.length === 0 && (
|
||||||
<Center h='100%'>
|
<Center h="100%">
|
||||||
<Stack align="center" gap="md">
|
<Stack align="center" gap="md">
|
||||||
<Text size="lg" c="dimmed">📄</Text>
|
<Text size="lg" c="dimmed">
|
||||||
|
📄
|
||||||
|
</Text>
|
||||||
<Text c="dimmed">No PDF files loaded</Text>
|
<Text c="dimmed">No PDF files loaded</Text>
|
||||||
<Text size="sm" c="dimmed">Add files to start editing pages</Text>
|
<Text size="sm" c="dimmed">
|
||||||
|
Add files to start editing pages
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
)}
|
)}
|
||||||
@@ -623,19 +653,19 @@ const PageEditor = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{displayDocument && (
|
{displayDocument && (
|
||||||
<Box ref={gridContainerRef} p={0} pt="2rem" pb="4rem" style={{ position: 'relative' }}>
|
<Box ref={gridContainerRef} p={0} pt="2rem" pb="4rem" style={{ position: "relative" }}>
|
||||||
{/* Split Lines Overlay */}
|
{/* Split Lines Overlay */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
pointerEvents: 'none',
|
pointerEvents: "none",
|
||||||
zIndex: 10
|
zIndex: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const refsMap = gridItemRefsRef.current?.current;
|
const refsMap = gridItemRefsRef.current?.current;
|
||||||
const containerEl = gridContainerRef.current;
|
const containerEl = gridContainerRef.current;
|
||||||
@@ -682,12 +712,12 @@ const PageEditor = ({
|
|||||||
<div
|
<div
|
||||||
key={`split-${position}`}
|
key={`split-${position}`}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
left: `${lineLeft - containerRect.left}px`,
|
left: `${lineLeft - containerRect.left}px`,
|
||||||
top: `${currentRect.top - containerRect.top}px`,
|
top: `${currentRect.top - containerRect.top}px`,
|
||||||
width: '1px',
|
width: "1px",
|
||||||
height: `${currentRect.height}px`,
|
height: `${currentRect.height}px`,
|
||||||
borderLeft: '1px dashed #3b82f6',
|
borderLeft: "1px dashed #3b82f6",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -704,18 +734,17 @@ const PageEditor = ({
|
|||||||
selectedPageIds={selectedPageIds}
|
selectedPageIds={selectedPageIds}
|
||||||
onVisibleItemsChange={handleVisibleItemsChange}
|
onVisibleItemsChange={handleVisibleItemsChange}
|
||||||
getThumbnailData={(pageId) => {
|
getThumbnailData={(pageId) => {
|
||||||
const page = displayDocument.pages.find(p => p.id === pageId);
|
const page = displayDocument.pages.find((p) => p.id === pageId);
|
||||||
if (!page?.thumbnail) return null;
|
if (!page?.thumbnail) return null;
|
||||||
return {
|
return {
|
||||||
src: page.thumbnail,
|
src: page.thumbnail,
|
||||||
rotation: page.rotation || 0
|
rotation: page.rotation || 0,
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
renderItem={renderItemCallback}
|
renderItem={renderItemCallback}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { Tooltip, ActionIcon } from "@mantine/core";
|
||||||
Tooltip,
|
|
||||||
ActionIcon,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import UndoIcon from "@mui/icons-material/Undo";
|
import UndoIcon from "@mui/icons-material/Undo";
|
||||||
import RedoIcon from "@mui/icons-material/Redo";
|
import RedoIcon from "@mui/icons-material/Redo";
|
||||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||||
@@ -21,7 +18,7 @@ interface PageEditorControlsProps {
|
|||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
|
|
||||||
// Page operations
|
// Page operations
|
||||||
onRotate: (direction: 'left' | 'right') => void;
|
onRotate: (direction: "left" | "right") => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onSplit: () => void;
|
onSplit: () => void;
|
||||||
onSplitAll: () => void;
|
onSplitAll: () => void;
|
||||||
@@ -64,93 +61,102 @@ const PageEditorControls = ({
|
|||||||
const totalPages = displayDocument.pages.length;
|
const totalPages = displayDocument.pages.length;
|
||||||
const selectedValidPageIds = displayDocument.pages
|
const selectedValidPageIds = displayDocument.pages
|
||||||
.filter((page, index) => selectedPageIds.includes(page.id) && index < totalPages - 1)
|
.filter((page, index) => selectedPageIds.includes(page.id) && index < totalPages - 1)
|
||||||
.map(page => page.id);
|
.map((page) => page.id);
|
||||||
|
|
||||||
if (selectedValidPageIds.length === 0) {
|
if (selectedValidPageIds.length === 0) {
|
||||||
return "Split Selected";
|
return "Split Selected";
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSplitsCount = selectedValidPageIds.filter(id => splitPositions.has(id)).length;
|
const existingSplitsCount = selectedValidPageIds.filter((id) => splitPositions.has(id)).length;
|
||||||
const noSplitsCount = selectedValidPageIds.length - existingSplitsCount;
|
const noSplitsCount = selectedValidPageIds.length - existingSplitsCount;
|
||||||
|
|
||||||
const willRemoveSplits = existingSplitsCount > noSplitsCount;
|
const willRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||||
|
|
||||||
if (willRemoveSplits) {
|
if (willRemoveSplits) {
|
||||||
return existingSplitsCount === selectedValidPageIds.length
|
return existingSplitsCount === selectedValidPageIds.length ? "Remove All Selected Splits" : "Remove Selected Splits";
|
||||||
? "Remove All Selected Splits"
|
|
||||||
: "Remove Selected Splits";
|
|
||||||
} else {
|
} else {
|
||||||
return existingSplitsCount === 0
|
return existingSplitsCount === 0 ? "Split Selected" : "Complete Selected Splits";
|
||||||
? "Split Selected"
|
|
||||||
: "Complete Selected Splits";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate page break tooltip text
|
// Calculate page break tooltip text
|
||||||
const getPageBreakTooltip = () => {
|
const getPageBreakTooltip = () => {
|
||||||
return selectedPageIds.length > 0
|
return selectedPageIds.length > 0
|
||||||
? `Insert ${selectedPageIds.length} Page Break${selectedPageIds.length > 1 ? 's' : ''}`
|
? `Insert ${selectedPageIds.length} Page Break${selectedPageIds.length > 1 ? "s" : ""}`
|
||||||
: "Insert Page Breaks";
|
: "Insert Page Breaks";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: "sticky",
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
pointerEvents: 'none',
|
pointerEvents: "none",
|
||||||
background: 'transparent',
|
background: "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
borderTopLeftRadius: 16,
|
borderTopLeftRadius: 16,
|
||||||
borderTopRightRadius: 16,
|
borderTopRightRadius: 16,
|
||||||
borderBottomLeftRadius: 0,
|
borderBottomLeftRadius: 0,
|
||||||
borderBottomRightRadius: 0,
|
borderBottomRightRadius: 0,
|
||||||
boxShadow: '0 -2px 8px rgba(0,0,0,0.04)',
|
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
|
||||||
backgroundColor: 'var(--bg-toolbar)',
|
backgroundColor: "var(--bg-toolbar)",
|
||||||
border: '1px solid var(--border-default)',
|
border: "1px solid var(--border-default)",
|
||||||
borderRadius: '16px 16px 0 0',
|
borderRadius: "16px 16px 0 0",
|
||||||
pointerEvents: 'auto',
|
pointerEvents: "auto",
|
||||||
minWidth: 360,
|
minWidth: 360,
|
||||||
maxWidth: 700,
|
maxWidth: 700,
|
||||||
flexWrap: 'wrap',
|
flexWrap: "wrap",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
paddingBottom: "1rem"
|
paddingBottom: "1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
||||||
{/* Undo/Redo */}
|
{/* Undo/Redo */}
|
||||||
<Tooltip label="Undo">
|
<Tooltip label="Undo">
|
||||||
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" style={{ color: canUndo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
|
<ActionIcon
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={!canUndo}
|
||||||
|
variant="subtle"
|
||||||
|
style={{ color: canUndo ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||||
|
radius="md"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
<UndoIcon />
|
<UndoIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label="Redo">
|
<Tooltip label="Redo">
|
||||||
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" style={{ color: canRedo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
|
<ActionIcon
|
||||||
|
onClick={onRedo}
|
||||||
|
disabled={!canRedo}
|
||||||
|
variant="subtle"
|
||||||
|
style={{ color: canRedo ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||||
|
radius="md"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
<RedoIcon />
|
<RedoIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
<div style={{ width: 1, height: 28, backgroundColor: "var(--mantine-color-gray-3)", margin: "0 8px" }} />
|
||||||
|
|
||||||
{/* Page Operations */}
|
{/* Page Operations */}
|
||||||
<Tooltip label="Rotate Selected Left">
|
<Tooltip label="Rotate Selected Left">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => onRotate('left')}
|
onClick={() => onRotate("left")}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -159,10 +165,10 @@ const PageEditorControls = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label="Rotate Selected Right">
|
<Tooltip label="Rotate Selected Right">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => onRotate('right')}
|
onClick={() => onRotate("right")}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -174,7 +180,7 @@ const PageEditorControls = ({
|
|||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -186,7 +192,7 @@ const PageEditorControls = ({
|
|||||||
onClick={onSplit}
|
onClick={onSplit}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -198,7 +204,7 @@ const PageEditorControls = ({
|
|||||||
onClick={onPageBreak}
|
onClick={onPageBreak}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ActionIcon, Popover } from '@mantine/core';
|
import { ActionIcon, Popover } from "@mantine/core";
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||||
import BulkSelectionPanel from '@app/components/pageEditor/BulkSelectionPanel';
|
import BulkSelectionPanel from "@app/components/pageEditor/BulkSelectionPanel";
|
||||||
|
|
||||||
interface PageSelectByNumberButtonProps {
|
interface PageSelectByNumberButtonProps {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
@@ -29,7 +29,7 @@ export default function PageSelectByNumberButton({
|
|||||||
<div className={`right-rail-fade enter`}>
|
<div className={`right-rail-fade enter`}>
|
||||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<div style={{ display: 'inline-flex' }}>
|
<div style={{ display: "inline-flex" }}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -42,7 +42,7 @@ export default function PageSelectByNumberButton({
|
|||||||
</div>
|
</div>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
|
<div style={{ minWidth: "24rem", maxWidth: "32rem" }}>
|
||||||
<BulkSelectionPanel
|
<BulkSelectionPanel
|
||||||
csvInput={csvInput}
|
csvInput={csvInput}
|
||||||
setCsvInput={setCsvInput}
|
setCsvInput={setCsvInput}
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
|
import React, { useCallback, useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { Text, Checkbox } from '@mantine/core';
|
import { Text, Checkbox } from "@mantine/core";
|
||||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||||
import RotateRightIcon from '@mui/icons-material/RotateRight';
|
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import { PDFPage, PDFDocument } from '@app/types/pageEditor';
|
import { PDFPage, PDFDocument } from "@app/types/pageEditor";
|
||||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||||
import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors';
|
import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors";
|
||||||
import styles from '@app/components/pageEditor/PageEditor.module.css';
|
import styles from "@app/components/pageEditor/PageEditor.module.css";
|
||||||
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu";
|
||||||
import { StirlingFileStub } from '@app/types/fileContext';
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||||
|
|
||||||
|
|
||||||
interface PageThumbnailProps {
|
interface PageThumbnailProps {
|
||||||
page: PDFPage;
|
page: PDFPage;
|
||||||
@@ -81,7 +80,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
const isSelected = Array.isArray(selectedPageIds) ? selectedPageIds.includes(page.id) : false;
|
const isSelected = Array.isArray(selectedPageIds) ? selectedPageIds.includes(page.id) : false;
|
||||||
|
|
||||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
const [mouseStartPos, setMouseStartPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const lastClickTimeRef = useRef<number>(0);
|
const lastClickTimeRef = useRef<number>(0);
|
||||||
@@ -96,13 +95,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
// Calculate document aspect ratio from first non-blank page
|
// Calculate document aspect ratio from first non-blank page
|
||||||
const getDocumentAspectRatio = useCallback(() => {
|
const getDocumentAspectRatio = useCallback(() => {
|
||||||
// Find first non-blank page with a thumbnail to get aspect ratio
|
// Find first non-blank page with a thumbnail to get aspect ratio
|
||||||
const firstRealPage = pdfDocument.pages.find(p => !p.isBlankPage && p.thumbnail);
|
const firstRealPage = pdfDocument.pages.find((p) => !p.isBlankPage && p.thumbnail);
|
||||||
if (firstRealPage?.thumbnail) {
|
if (firstRealPage?.thumbnail) {
|
||||||
// Try to get aspect ratio from an actual thumbnail image
|
// Try to get aspect ratio from an actual thumbnail image
|
||||||
// For now, default to A4 but could be enhanced to measure image dimensions
|
// For now, default to A4 but could be enhanced to measure image dimensions
|
||||||
return '1 / 1.414'; // A4 ratio as fallback
|
return "1 / 1.414"; // A4 ratio as fallback
|
||||||
}
|
}
|
||||||
return '1 / 1.414'; // Default A4 ratio
|
return "1 / 1.414"; // Default A4 ratio
|
||||||
}, [pdfDocument.pages]);
|
}, [pdfDocument.pages]);
|
||||||
|
|
||||||
// Update thumbnail URL when page prop changes
|
// Update thumbnail URL when page prop changes
|
||||||
@@ -113,77 +112,94 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
}, [page.thumbnail, thumbnailUrl]);
|
}, [page.thumbnail, thumbnailUrl]);
|
||||||
|
|
||||||
// Merge refs - combine our ref tracking with dnd-kit's ref
|
// Merge refs - combine our ref tracking with dnd-kit's ref
|
||||||
const mergedRef = useCallback((element: HTMLDivElement | null) => {
|
const mergedRef = useCallback(
|
||||||
// Track in our refs map
|
(element: HTMLDivElement | null) => {
|
||||||
elementRef.current = element;
|
// Track in our refs map
|
||||||
if (element) {
|
elementRef.current = element;
|
||||||
pageRefs.current.set(page.id, element);
|
if (element) {
|
||||||
} else {
|
pageRefs.current.set(page.id, element);
|
||||||
pageRefs.current.delete(page.id);
|
} else {
|
||||||
}
|
pageRefs.current.delete(page.id);
|
||||||
|
}
|
||||||
// Call dnd-kit's ref if provided
|
|
||||||
if (dragHandleProps?.ref) {
|
|
||||||
dragHandleProps.ref(element);
|
|
||||||
}
|
|
||||||
}, [page.id, pageRefs, dragHandleProps]);
|
|
||||||
|
|
||||||
|
// Call dnd-kit's ref if provided
|
||||||
|
if (dragHandleProps?.ref) {
|
||||||
|
dragHandleProps.ref(element);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[page.id, pageRefs, dragHandleProps],
|
||||||
|
);
|
||||||
|
|
||||||
// DOM command handlers
|
// DOM command handlers
|
||||||
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
|
const handleRotateLeft = useCallback(
|
||||||
e.stopPropagation();
|
(e: React.MouseEvent) => {
|
||||||
// Use the command system for undo/redo support
|
e.stopPropagation();
|
||||||
const command = createRotateCommand([page.id], -90);
|
// Use the command system for undo/redo support
|
||||||
onExecuteCommand(command);
|
const command = createRotateCommand([page.id], -90);
|
||||||
onSetStatus(`Rotated page ${page.pageNumber} left`);
|
onExecuteCommand(command);
|
||||||
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
|
onSetStatus(`Rotated page ${page.pageNumber} left`);
|
||||||
|
},
|
||||||
|
[page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand],
|
||||||
|
);
|
||||||
|
|
||||||
const handleRotateRight = useCallback((e: React.MouseEvent) => {
|
const handleRotateRight = useCallback(
|
||||||
e.stopPropagation();
|
(e: React.MouseEvent) => {
|
||||||
// Use the command system for undo/redo support
|
e.stopPropagation();
|
||||||
const command = createRotateCommand([page.id], 90);
|
// Use the command system for undo/redo support
|
||||||
onExecuteCommand(command);
|
const command = createRotateCommand([page.id], 90);
|
||||||
onSetStatus(`Rotated page ${page.pageNumber} right`);
|
onExecuteCommand(command);
|
||||||
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
|
onSetStatus(`Rotated page ${page.pageNumber} right`);
|
||||||
|
},
|
||||||
|
[page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback((e: React.MouseEvent) => {
|
const handleDelete = useCallback(
|
||||||
e.stopPropagation();
|
(e: React.MouseEvent) => {
|
||||||
onDeletePage(page.pageNumber);
|
e.stopPropagation();
|
||||||
onSetStatus(`Deleted page ${page.pageNumber}`);
|
onDeletePage(page.pageNumber);
|
||||||
}, [page.pageNumber, onDeletePage, onSetStatus]);
|
onSetStatus(`Deleted page ${page.pageNumber}`);
|
||||||
|
},
|
||||||
|
[page.pageNumber, onDeletePage, onSetStatus],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSplit = useCallback((e: React.MouseEvent) => {
|
const handleSplit = useCallback(
|
||||||
e.stopPropagation();
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
// Create a command to toggle split at this position
|
// Create a command to toggle split at this position
|
||||||
const command = createSplitCommand(page.id, page.pageNumber);
|
const command = createSplitCommand(page.id, page.pageNumber);
|
||||||
onExecuteCommand(command);
|
onExecuteCommand(command);
|
||||||
|
|
||||||
const hasSplit = splitPositions.has(page.id);
|
const hasSplit = splitPositions.has(page.id);
|
||||||
const action = hasSplit ? 'removed' : 'added';
|
const action = hasSplit ? "removed" : "added";
|
||||||
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
|
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
|
||||||
}, [pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
|
},
|
||||||
|
[pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand],
|
||||||
|
);
|
||||||
|
|
||||||
const handleInsertFileAfter = useCallback((e: React.MouseEvent) => {
|
const handleInsertFileAfter = useCallback(
|
||||||
e.stopPropagation();
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
if (onInsertFiles) {
|
if (onInsertFiles) {
|
||||||
// Open file manager modal with custom handler for page insertion
|
// Open file manager modal with custom handler for page insertion
|
||||||
openFilesModal({
|
openFilesModal({
|
||||||
insertAfterPage: page.pageNumber,
|
insertAfterPage: page.pageNumber,
|
||||||
customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => {
|
customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => {
|
||||||
if (insertAfterPage !== undefined) {
|
if (insertAfterPage !== undefined) {
|
||||||
onInsertFiles(files, insertAfterPage, isFromStorage);
|
onInsertFiles(files, insertAfterPage, isFromStorage);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
|
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to normal file handling
|
// Fallback to normal file handling
|
||||||
openFilesModal({ insertAfterPage: page.pageNumber });
|
openFilesModal({ insertAfterPage: page.pageNumber });
|
||||||
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
|
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
|
||||||
}
|
}
|
||||||
}, [openFilesModal, page.pageNumber, onSetStatus, onInsertFiles]);
|
},
|
||||||
|
[openFilesModal, page.pageNumber, onSetStatus, onInsertFiles],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle click vs drag differentiation
|
// Handle click vs drag differentiation
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
@@ -191,40 +207,43 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
setMouseStartPos({ x: e.clientX, y: e.clientY });
|
setMouseStartPos({ x: e.clientX, y: e.clientY });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
const handleMouseUp = useCallback(
|
||||||
if (!isMouseDown || !mouseStartPos) {
|
(e: React.MouseEvent) => {
|
||||||
setIsMouseDown(false);
|
if (!isMouseDown || !mouseStartPos) {
|
||||||
setMouseStartPos(null);
|
setIsMouseDown(false);
|
||||||
return;
|
setMouseStartPos(null);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate distance moved
|
// Calculate distance moved
|
||||||
const deltaX = Math.abs(e.clientX - mouseStartPos.x);
|
const deltaX = Math.abs(e.clientX - mouseStartPos.x);
|
||||||
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
|
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
|
||||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||||
|
|
||||||
// If mouse moved less than 2 pixels, consider it a click (not a drag)
|
// If mouse moved less than 2 pixels, consider it a click (not a drag)
|
||||||
if (distance < 2 && !isDragging) {
|
if (distance < 2 && !isDragging) {
|
||||||
// Prevent rapid double-clicks from causing issues (debounce with 100ms threshold)
|
// Prevent rapid double-clicks from causing issues (debounce with 100ms threshold)
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastClickTimeRef.current > 100) {
|
if (now - lastClickTimeRef.current > 100) {
|
||||||
lastClickTimeRef.current = now;
|
lastClickTimeRef.current = now;
|
||||||
|
|
||||||
// Clear box selection when clicking on a non-selected page
|
// Clear box selection when clicking on a non-selected page
|
||||||
if (!isBoxSelected && clearBoxSelection) {
|
if (!isBoxSelected && clearBoxSelection) {
|
||||||
clearBoxSelection();
|
clearBoxSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't toggle page selection if it's box-selected (just keep the box selection)
|
// Don't toggle page selection if it's box-selected (just keep the box selection)
|
||||||
if (!isBoxSelected) {
|
if (!isBoxSelected) {
|
||||||
onTogglePage(page.id);
|
onTogglePage(page.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setIsMouseDown(false);
|
setIsMouseDown(false);
|
||||||
setMouseStartPos(null);
|
setMouseStartPos(null);
|
||||||
}, [isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage]);
|
},
|
||||||
|
[isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage],
|
||||||
|
);
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
setIsMouseDown(false);
|
setIsMouseDown(false);
|
||||||
@@ -232,79 +251,96 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
setIsHovered(false);
|
setIsHovered(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fileColorBorder = page.isBlankPage ? 'transparent' : getFileColorWithOpacity(fileColorIndex, 0.3);
|
const fileColorBorder = page.isBlankPage ? "transparent" : getFileColorWithOpacity(fileColorIndex, 0.3);
|
||||||
|
|
||||||
// Spread dragHandleProps but use our merged ref
|
// Spread dragHandleProps but use our merged ref
|
||||||
const { ref: _, ...restDragProps } = dragHandleProps || {};
|
const { ref: _, ...restDragProps } = dragHandleProps || {};
|
||||||
|
|
||||||
// Build hover menu actions
|
// Build hover menu actions
|
||||||
const hoverActions = useMemo<HoverAction[]>(() => [
|
const hoverActions = useMemo<HoverAction[]>(
|
||||||
{
|
() => [
|
||||||
id: 'move-left',
|
{
|
||||||
icon: <ArrowBackIcon style={{ fontSize: 20 }} />,
|
id: "move-left",
|
||||||
label: 'Move Left',
|
icon: <ArrowBackIcon style={{ fontSize: 20 }} />,
|
||||||
onClick: (e) => {
|
label: "Move Left",
|
||||||
e.stopPropagation();
|
onClick: (e) => {
|
||||||
if (pageIndex > 0 && !movingPage && !isAnimating) {
|
e.stopPropagation();
|
||||||
onSetMovingPage(page.pageNumber);
|
if (pageIndex > 0 && !movingPage && !isAnimating) {
|
||||||
onReorderPages(page.pageNumber, pageIndex - 1);
|
onSetMovingPage(page.pageNumber);
|
||||||
setTimeout(() => onSetMovingPage(null), 650);
|
onReorderPages(page.pageNumber, pageIndex - 1);
|
||||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
setTimeout(() => onSetMovingPage(null), 650);
|
||||||
}
|
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
disabled: pageIndex === 0,
|
||||||
},
|
},
|
||||||
disabled: pageIndex === 0
|
{
|
||||||
},
|
id: "move-right",
|
||||||
{
|
icon: <ArrowForwardIcon style={{ fontSize: 20 }} />,
|
||||||
id: 'move-right',
|
label: "Move Right",
|
||||||
icon: <ArrowForwardIcon style={{ fontSize: 20 }} />,
|
onClick: (e) => {
|
||||||
label: 'Move Right',
|
e.stopPropagation();
|
||||||
onClick: (e) => {
|
if (pageIndex < totalPages - 1 && !movingPage && !isAnimating) {
|
||||||
e.stopPropagation();
|
onSetMovingPage(page.pageNumber);
|
||||||
if (pageIndex < totalPages - 1 && !movingPage && !isAnimating) {
|
// ReorderPagesCommand expects target index relative to the original array.
|
||||||
onSetMovingPage(page.pageNumber);
|
// When moving toward the right (higher index), provide desiredIndex + 1
|
||||||
// ReorderPagesCommand expects target index relative to the original array.
|
// so the command's internal adjustment (targetIndex - 1) lands correctly.
|
||||||
// When moving toward the right (higher index), provide desiredIndex + 1
|
onReorderPages(page.pageNumber, pageIndex + 2);
|
||||||
// so the command's internal adjustment (targetIndex - 1) lands correctly.
|
setTimeout(() => onSetMovingPage(null), 650);
|
||||||
onReorderPages(page.pageNumber, pageIndex + 2);
|
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||||
setTimeout(() => onSetMovingPage(null), 650);
|
}
|
||||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
},
|
||||||
}
|
disabled: pageIndex === totalPages - 1,
|
||||||
},
|
},
|
||||||
disabled: pageIndex === totalPages - 1
|
{
|
||||||
},
|
id: "rotate-left",
|
||||||
{
|
icon: <RotateLeftIcon style={{ fontSize: 20 }} />,
|
||||||
id: 'rotate-left',
|
label: "Rotate Left",
|
||||||
icon: <RotateLeftIcon style={{ fontSize: 20 }} />,
|
onClick: handleRotateLeft,
|
||||||
label: 'Rotate Left',
|
},
|
||||||
onClick: handleRotateLeft,
|
{
|
||||||
},
|
id: "rotate-right",
|
||||||
{
|
icon: <RotateRightIcon style={{ fontSize: 20 }} />,
|
||||||
id: 'rotate-right',
|
label: "Rotate Right",
|
||||||
icon: <RotateRightIcon style={{ fontSize: 20 }} />,
|
onClick: handleRotateRight,
|
||||||
label: 'Rotate Right',
|
},
|
||||||
onClick: handleRotateRight,
|
{
|
||||||
},
|
id: "delete",
|
||||||
{
|
icon: <DeleteIcon style={{ fontSize: 20 }} />,
|
||||||
id: 'delete',
|
label: "Delete Page",
|
||||||
icon: <DeleteIcon style={{ fontSize: 20 }} />,
|
onClick: handleDelete,
|
||||||
label: 'Delete Page',
|
color: "red",
|
||||||
onClick: handleDelete,
|
},
|
||||||
color: 'red',
|
{
|
||||||
},
|
id: "split",
|
||||||
{
|
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
|
||||||
id: 'split',
|
label: "Split After",
|
||||||
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
|
onClick: handleSplit,
|
||||||
label: 'Split After',
|
hidden: pageIndex >= totalPages - 1,
|
||||||
onClick: handleSplit,
|
},
|
||||||
hidden: pageIndex >= totalPages - 1,
|
{
|
||||||
},
|
id: "insert",
|
||||||
{
|
icon: <AddIcon style={{ fontSize: 20 }} />,
|
||||||
id: 'insert',
|
label: "Insert File After",
|
||||||
icon: <AddIcon style={{ fontSize: 20 }} />,
|
onClick: handleInsertFileAfter,
|
||||||
label: 'Insert File After',
|
},
|
||||||
onClick: handleInsertFileAfter,
|
],
|
||||||
}
|
[
|
||||||
], [pageIndex, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]);
|
pageIndex,
|
||||||
|
totalPages,
|
||||||
|
movingPage,
|
||||||
|
isAnimating,
|
||||||
|
page.pageNumber,
|
||||||
|
handleRotateLeft,
|
||||||
|
handleRotateRight,
|
||||||
|
handleDelete,
|
||||||
|
handleSplit,
|
||||||
|
handleInsertFileAfter,
|
||||||
|
onReorderPages,
|
||||||
|
onSetMovingPage,
|
||||||
|
onSetStatus,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -315,7 +351,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
className={`
|
className={`
|
||||||
${styles.pageContainer}
|
${styles.pageContainer}
|
||||||
!rounded-lg
|
!rounded-lg
|
||||||
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
|
${selectionMode ? "cursor-pointer" : "cursor-grab"}
|
||||||
select-none
|
select-none
|
||||||
flex items-center justify-center
|
flex items-center justify-center
|
||||||
flex-shrink-0
|
flex-shrink-0
|
||||||
@@ -323,17 +359,17 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
hover:shadow-md
|
hover:shadow-md
|
||||||
transition-all
|
transition-all
|
||||||
relative
|
relative
|
||||||
${isDragging ? 'opacity-50 scale-95' : ''}
|
${isDragging ? "opacity-50 scale-95" : ""}
|
||||||
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
${movingPage === page.pageNumber ? "page-moving" : ""}
|
||||||
${isBoxSelected ? 'ring-4 ring-blue-400 ring-offset-2' : ''}
|
${isBoxSelected ? "ring-4 ring-blue-400 ring-offset-2" : ""}
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
width: `calc(20rem * ${zoomLevel})`,
|
width: `calc(20rem * ${zoomLevel})`,
|
||||||
height: `calc(20rem * ${zoomLevel})`,
|
height: `calc(20rem * ${zoomLevel})`,
|
||||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
|
transition: isAnimating ? "none" : "transform 0.2s ease-in-out",
|
||||||
zIndex: isHovered ? 50 : 1,
|
zIndex: isHovered ? 50 : 1,
|
||||||
...(isBoxSelected && {
|
...(isBoxSelected && {
|
||||||
boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.5)',
|
boxShadow: "0 0 0 4px rgba(59, 130, 246, 0.5)",
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
@@ -345,15 +381,15 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={styles.checkboxContainer}
|
className={styles.checkboxContainer}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
backgroundColor: 'white',
|
backgroundColor: "white",
|
||||||
borderRadius: '4px',
|
borderRadius: "4px",
|
||||||
padding: '2px',
|
padding: "2px",
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||||
pointerEvents: 'auto'
|
pointerEvents: "auto",
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -371,41 +407,45 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
// Selection is handled by container mouseDown
|
// Selection is handled by container mouseDown
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{ pointerEvents: "none" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
||||||
<div
|
<div
|
||||||
className={`${styles.pageSurface} ${justMoved ? styles.pageJustMoved : ''}`}
|
className={`${styles.pageSurface} ${justMoved ? styles.pageJustMoved : ""}`}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: "100%",
|
||||||
height: '100%',
|
height: "100%",
|
||||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
backgroundColor: "var(--mantine-color-gray-1)",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
boxShadow: page.isBlankPage ? 'none' : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
|
boxShadow: page.isBlankPage ? "none" : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center'
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{page.isBlankPage ? (
|
{page.isBlankPage ? (
|
||||||
<div style={{
|
<div
|
||||||
width: '100%',
|
style={{
|
||||||
height: '100%',
|
width: "100%",
|
||||||
display: 'flex',
|
height: "100%",
|
||||||
alignItems: 'center',
|
display: "flex",
|
||||||
justifyContent: 'center'
|
alignItems: "center",
|
||||||
}}>
|
justifyContent: "center",
|
||||||
<div style={{
|
}}
|
||||||
width: '70%',
|
>
|
||||||
aspectRatio: getDocumentAspectRatio(),
|
<div
|
||||||
backgroundColor: 'white',
|
style={{
|
||||||
border: '1px solid #e9ecef',
|
width: "70%",
|
||||||
borderRadius: 2
|
aspectRatio: getDocumentAspectRatio(),
|
||||||
}} />
|
backgroundColor: "white",
|
||||||
|
border: "1px solid #e9ecef",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : thumbnailUrl ? (
|
) : thumbnailUrl ? (
|
||||||
<PrivateContent>
|
<PrivateContent>
|
||||||
@@ -416,19 +456,23 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
data-original-rotation={page.rotation}
|
data-original-rotation={page.rotation}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: "100%",
|
||||||
height: '100%',
|
height: "100%",
|
||||||
objectFit: 'contain',
|
objectFit: "contain",
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
transform: `rotate(${page.rotation}deg)`,
|
transform: `rotate(${page.rotation}deg)`,
|
||||||
transition: 'transform 0.3s ease-in-out'
|
transition: "transform 0.3s ease-in-out",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PrivateContent>
|
</PrivateContent>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: "center" }}>
|
||||||
<Text size="lg" c="dimmed">📄</Text>
|
<Text size="lg" c="dimmed">
|
||||||
<Text size="xs" c="dimmed" mt={4}>Page {page.pageNumber}</Text>
|
📄
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
Page {page.pageNumber}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -438,16 +482,16 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
fw={500}
|
fw={500}
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--mantine-color-white)', // Use theme token for consistency
|
color: "var(--mantine-color-white)", // Use theme token for consistency
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 5,
|
top: 5,
|
||||||
left: 5,
|
left: 5,
|
||||||
background: 'rgba(162, 201, 255, 0.8)',
|
background: "rgba(162, 201, 255, 0.8)",
|
||||||
padding: '6px 8px',
|
padding: "6px 8px",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
transition: 'opacity 0.2s ease-in-out'
|
transition: "opacity 0.2s ease-in-out",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{page.pageNumber}
|
{page.pageNumber}
|
||||||
@@ -459,9 +503,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
position="inside"
|
position="inside"
|
||||||
className={styles.pageHoverControls}
|
className={styles.pageHoverControls}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user