mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 22:29:24 +00:00
Merge branch 'V2' into V2-merge
# Conflicts: # frontend/src/components/tools/shared/FileStatusIndicator.tsx
This commit is contained in:
commit
9db31f2d18
6
frontend/.gitignore
vendored
6
frontend/.gitignore
vendored
@ -24,4 +24,8 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
playwright-report
|
playwright-report
|
||||||
test-results
|
test-results
|
||||||
|
|
||||||
|
# auto-generated files
|
||||||
|
/src/assets/material-symbols-icons.json
|
||||||
|
/src/assets/material-symbols-icons.d.ts
|
170
frontend/package-lock.json
generated
170
frontend/package-lock.json
generated
@ -12,6 +12,7 @@
|
|||||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
|
"@iconify/react": "^6.0.0",
|
||||||
"@mantine/core": "^8.0.1",
|
"@mantine/core": "^8.0.1",
|
||||||
"@mantine/dropzone": "^8.0.1",
|
"@mantine/dropzone": "^8.0.1",
|
||||||
"@mantine/hooks": "^8.0.1",
|
"@mantine/hooks": "^8.0.1",
|
||||||
@ -29,7 +30,6 @@
|
|||||||
"i18next-browser-languagedetector": "^8.1.0",
|
"i18next-browser-languagedetector": "^8.1.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"material-symbols": "^0.33.0",
|
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^3.11.174",
|
"pdfjs-dist": "^3.11.174",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
@ -40,6 +40,8 @@
|
|||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@iconify-json/material-symbols": "^1.2.33",
|
||||||
|
"@iconify/utils": "^3.0.1",
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
"@types/node": "^24.2.1",
|
"@types/node": "^24.2.1",
|
||||||
"@types/react": "^19.1.4",
|
"@types/react": "^19.1.4",
|
||||||
@ -89,6 +91,28 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@antfu/install-pkg": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"package-manager-detector": "^1.3.0",
|
||||||
|
"tinyexec": "^1.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@antfu/utils": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-Oq1d9BGZakE/FyoEtcNeSwM7MpDO2vUBi11RWBZXf75zPsbUVWmUs03EqkRFrcgbXyKTas0BdZWC1wcuSoqSAw==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||||
@ -1192,6 +1216,104 @@
|
|||||||
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@iconify-json/material-symbols": {
|
||||||
|
"version": "1.2.33",
|
||||||
|
"resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.33.tgz",
|
||||||
|
"integrity": "sha512-Bs0X1+/vpJydW63olrGh60zkR8/Y70sI14AIWaP7Z6YQXukzWANH4q3I0sIPklbIn1oL6uwLvh0zQyd6Vh79LQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@iconify/types": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@iconify/react": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@iconify/react/-/react-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-eqNscABVZS8eCpZLU/L5F5UokMS9mnCf56iS1nM9YYHdH8ZxqZL9zyjSwW60IOQFsXZkilbBiv+1paMXBhSQnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@iconify/types": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/cyberalien"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@iconify/types": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@iconify/utils": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-A78CUEnFGX8I/WlILxJCuIJXloL0j/OJ9PSchPAfCargEIKmUBWvvEMmKWB5oONwiUqlNt+5eRufdkLxeHIWYw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@antfu/install-pkg": "^1.1.0",
|
||||||
|
"@antfu/utils": "^9.2.0",
|
||||||
|
"@iconify/types": "^2.0.0",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"globals": "^15.15.0",
|
||||||
|
"kolorist": "^1.8.0",
|
||||||
|
"local-pkg": "^1.1.1",
|
||||||
|
"mlly": "^1.7.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@iconify/utils/node_modules/confbox": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@iconify/utils/node_modules/globals": {
|
||||||
|
"version": "15.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
|
||||||
|
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@iconify/utils/node_modules/local-pkg": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"mlly": "^1.7.4",
|
||||||
|
"pkg-types": "^2.3.0",
|
||||||
|
"quansync": "^0.2.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@iconify/utils/node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@iconify/utils/node_modules/pkg-types": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"confbox": "^0.2.2",
|
||||||
|
"exsolve": "^1.0.7",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/fs-minipass": {
|
"node_modules/@isaacs/fs-minipass": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||||
@ -4446,6 +4568,12 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/exsolve": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@ -5553,6 +5681,12 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kolorist": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/license-checker": {
|
"node_modules/license-checker": {
|
||||||
"version": "25.0.1",
|
"version": "25.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/license-checker/-/license-checker-25.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/license-checker/-/license-checker-25.0.1.tgz",
|
||||||
@ -6097,12 +6231,6 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/material-symbols": {
|
|
||||||
"version": "0.33.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.33.0.tgz",
|
|
||||||
"integrity": "sha512-t9/Gz+14fClRgN7oVOt5CBuwsjFLxSNP9BRDyMrI5el3IZNvoD94IDGJha0YYivyAow24rCS0WOkAv4Dp+YjNg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@ -6653,6 +6781,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/package-manager-detector": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/pako": {
|
"node_modules/pako": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
@ -7403,6 +7537,22 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/quansync": {
|
||||||
|
"version": "0.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||||
|
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/sxzz"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/querystringify": {
|
"node_modules/querystringify": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||||
@ -8615,6 +8765,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.13",
|
"version": "0.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
||||||
|
@ -8,6 +8,7 @@
|
|||||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
|
"@iconify/react": "^6.0.0",
|
||||||
"@mantine/core": "^8.0.1",
|
"@mantine/core": "^8.0.1",
|
||||||
"@mantine/dropzone": "^8.0.1",
|
"@mantine/dropzone": "^8.0.1",
|
||||||
"@mantine/hooks": "^8.0.1",
|
"@mantine/hooks": "^8.0.1",
|
||||||
@ -25,7 +26,6 @@
|
|||||||
"i18next-browser-languagedetector": "^8.1.0",
|
"i18next-browser-languagedetector": "^8.1.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"material-symbols": "^0.33.0",
|
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^3.11.174",
|
"pdfjs-dist": "^3.11.174",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
@ -36,10 +36,14 @@
|
|||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"predev": "npm run generate-icons",
|
||||||
"dev": "npx tsc --noEmit && vite",
|
"dev": "npx tsc --noEmit && vite",
|
||||||
|
"prebuild": "npm run generate-icons",
|
||||||
"build": "npx tsc --noEmit && vite build",
|
"build": "npx tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"generate-licenses": "node scripts/generate-licenses.js",
|
"generate-licenses": "node scripts/generate-licenses.js",
|
||||||
|
"generate-icons": "node scripts/generate-icons.js",
|
||||||
|
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:watch": "vitest --watch",
|
"test:watch": "vitest --watch",
|
||||||
"test:coverage": "vitest --coverage",
|
"test:coverage": "vitest --coverage",
|
||||||
@ -66,6 +70,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@iconify-json/material-symbols": "^1.2.33",
|
||||||
|
"@iconify/utils": "^3.0.1",
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
"@types/node": "^24.2.1",
|
"@types/node": "^24.2.1",
|
||||||
"@types/react": "^19.1.4",
|
"@types/react": "^19.1.4",
|
||||||
|
@ -48,7 +48,11 @@
|
|||||||
"filesSelected": "{{count}} files selected",
|
"filesSelected": "{{count}} files selected",
|
||||||
"files": {
|
"files": {
|
||||||
"title": "Files",
|
"title": "Files",
|
||||||
"placeholder": "Select a PDF file in the main view to get started"
|
"placeholder": "Select a PDF file in the main view to get started",
|
||||||
|
"upload": "Upload",
|
||||||
|
"addFiles": "Add files",
|
||||||
|
"noFiles": "No files uploaded. ",
|
||||||
|
"selectFromWorkbench": "Select files from the workbench or "
|
||||||
},
|
},
|
||||||
"noFavourites": "No favourites added",
|
"noFavourites": "No favourites added",
|
||||||
"downloadComplete": "Download Complete",
|
"downloadComplete": "Download Complete",
|
||||||
@ -604,6 +608,10 @@
|
|||||||
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"landing": {
|
||||||
|
"addFiles": "Add Files",
|
||||||
|
"uploadFromComputer": "Upload from computer"
|
||||||
|
},
|
||||||
"viewPdf": {
|
"viewPdf": {
|
||||||
"tags": "view,read,annotate,text,image,highlight,edit",
|
"tags": "view,read,annotate,text,image,highlight,edit",
|
||||||
"title": "View/Edit PDF",
|
"title": "View/Edit PDF",
|
||||||
@ -1114,7 +1122,88 @@
|
|||||||
},
|
},
|
||||||
"help": "Please read this documentation on how to use this for other languages and/or use not in docker",
|
"help": "Please read this documentation on how to use this for other languages and/or use not in docker",
|
||||||
"credit": "This service uses qpdf and Tesseract for OCR.",
|
"credit": "This service uses qpdf and Tesseract for OCR.",
|
||||||
"submit": "Process PDF with OCR"
|
"submit": "Process PDF with OCR",
|
||||||
|
"operation": {
|
||||||
|
"submit": "Process OCR and Review"
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"title": "OCR Results"
|
||||||
|
},
|
||||||
|
"languagePicker": {
|
||||||
|
"additionalLanguages": "Looking for additional languages?",
|
||||||
|
"viewSetupGuide": "View setup guide →"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"ocrMode": {
|
||||||
|
"label": "OCR Mode",
|
||||||
|
"auto": "Auto (skip text layers)",
|
||||||
|
"force": "Force (re-OCR all, replace text)",
|
||||||
|
"strict": "Strict (abort if text found)"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"label": "Languages",
|
||||||
|
"placeholder": "Select languages"
|
||||||
|
},
|
||||||
|
"compatibilityMode": {
|
||||||
|
"label": "Compatibility Mode"
|
||||||
|
},
|
||||||
|
"advancedOptions": {
|
||||||
|
"label": "Processing Options",
|
||||||
|
"sidecar": "Create a text file",
|
||||||
|
"deskew": "Deskew pages",
|
||||||
|
"clean": "Clean input file",
|
||||||
|
"cleanFinal": "Clean final output"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"header": {
|
||||||
|
"title": "OCR Settings Overview"
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"title": "OCR Mode",
|
||||||
|
"text": "Optical Character Recognition (OCR) helps you turn scanned or screenshotted pages into text you can search, copy, or highlight.",
|
||||||
|
"bullet1": "Auto skips pages that already contain text layers.",
|
||||||
|
"bullet2": "Force re-OCRs every page and replaces all the text.",
|
||||||
|
"bullet3": "Strict halts if any selectable text is found."
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"title": "Languages",
|
||||||
|
"text": "Improve OCR accuracy by specifying the expected languages. Choose one or more languages to guide detection."
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"title": "Output",
|
||||||
|
"text": "Decide how you want the text output formatted:",
|
||||||
|
"bullet1": "Searchable PDF embeds text behind the original image.",
|
||||||
|
"bullet2": "HOCR XML returns a structured machine-readable file.",
|
||||||
|
"bullet3": "Plain-text sidecar creates a separate .txt file with raw content."
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"header": {
|
||||||
|
"title": "Advanced OCR Processing"
|
||||||
|
},
|
||||||
|
"compatibility": {
|
||||||
|
"title": "Compatibility Mode",
|
||||||
|
"text": "Uses OCR 'sandwich PDF' mode: results in larger files, but more reliable with certain languages and older PDF software. By default we use hOCR for smaller, modern PDFs."
|
||||||
|
},
|
||||||
|
"sidecar": {
|
||||||
|
"title": "Create Text File",
|
||||||
|
"text": "Generates a separate .txt file alongside the PDF containing all extracted text content for easy access and processing."
|
||||||
|
},
|
||||||
|
"deskew": {
|
||||||
|
"title": "Deskew Pages",
|
||||||
|
"text": "Automatically corrects skewed or tilted pages to improve OCR accuracy. Useful for scanned documents that weren't perfectly aligned."
|
||||||
|
},
|
||||||
|
"clean": {
|
||||||
|
"title": "Clean Input File",
|
||||||
|
"text": "Preprocesses the input by removing noise, enhancing contrast, and optimising the image for better OCR recognition before processing."
|
||||||
|
},
|
||||||
|
"cleanFinal": {
|
||||||
|
"title": "Clean Final Output",
|
||||||
|
"text": "Post-processes the final PDF by removing OCR artefacts and optimising the text layer for better readability and smaller file size."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"extractImages": {
|
"extractImages": {
|
||||||
"tags": "picture,photo,save,archive,zip,capture,grab",
|
"tags": "picture,photo,save,archive,zip,capture,grab",
|
||||||
@ -1932,6 +2021,19 @@
|
|||||||
"currentPage": "Current Page",
|
"currentPage": "Current Page",
|
||||||
"totalPages": "Total Pages"
|
"totalPages": "Total Pages"
|
||||||
},
|
},
|
||||||
|
"rightRail": {
|
||||||
|
"closeSelected": "Close Selected Files",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"deselectAll": "Deselect All",
|
||||||
|
"selectByNumber": "Select by Page Numbers",
|
||||||
|
"deleteSelected": "Delete Selected Pages",
|
||||||
|
"closePdf": "Close PDF",
|
||||||
|
"exportAll": "Export PDF",
|
||||||
|
"downloadSelected": "Download Selected Files",
|
||||||
|
"downloadAll": "Download All",
|
||||||
|
"toggleTheme": "Toggle Theme",
|
||||||
|
"language": "Language"
|
||||||
|
},
|
||||||
"toolPicker": {
|
"toolPicker": {
|
||||||
"searchPlaceholder": "Search tools...",
|
"searchPlaceholder": "Search tools...",
|
||||||
"noToolsFound": "No tools found",
|
"noToolsFound": "No tools found",
|
||||||
@ -1980,6 +2082,7 @@
|
|||||||
"dropFilesHere": "Drop files here or click to upload",
|
"dropFilesHere": "Drop files here or click to upload",
|
||||||
"pdfFilesOnly": "PDF files only",
|
"pdfFilesOnly": "PDF files only",
|
||||||
"supportedFileTypes": "Supported file types",
|
"supportedFileTypes": "Supported file types",
|
||||||
|
"upload": "Upload",
|
||||||
"uploadFile": "Upload File",
|
"uploadFile": "Upload File",
|
||||||
"uploadFiles": "Upload Files",
|
"uploadFiles": "Upload Files",
|
||||||
"noFilesInStorage": "No files available in storage. Upload some files first.",
|
"noFilesInStorage": "No files available in storage. Upload some files first.",
|
||||||
@ -2266,6 +2369,20 @@
|
|||||||
"description": "Configure the settings for this tool. These settings will be applied when the automation runs.",
|
"description": "Configure the settings for this tool. These settings will be applied when the automation runs.",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save Configuration"
|
"save": "Save Configuration"
|
||||||
}
|
},
|
||||||
|
"copyToSaved": "Copy to Saved"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"automation": {
|
||||||
|
"suggested": {
|
||||||
|
"securePdfIngestion": "Secure PDF Ingestion",
|
||||||
|
"securePdfIngestionDesc": "Comprehensive PDF processing workflow that sanitises documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimises file size.",
|
||||||
|
"emailPreparation": "Email Preparation",
|
||||||
|
"emailPreparationDesc": "Optimises PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.",
|
||||||
|
"secureWorkflow": "Security Workflow",
|
||||||
|
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access.",
|
||||||
|
"processImages": "Process Images",
|
||||||
|
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -55,6 +55,7 @@
|
|||||||
"bored": "Bored Waiting?",
|
"bored": "Bored Waiting?",
|
||||||
"alphabet": "Alphabet",
|
"alphabet": "Alphabet",
|
||||||
"downloadPdf": "Download PDF",
|
"downloadPdf": "Download PDF",
|
||||||
|
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
"font": "Font",
|
"font": "Font",
|
||||||
"selectFillter": "-- Select --",
|
"selectFillter": "-- Select --",
|
||||||
@ -607,6 +608,10 @@
|
|||||||
"desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size"
|
"desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"landing": {
|
||||||
|
"addFiles": "Add Files",
|
||||||
|
"uploadFromComputer": "Upload from computer"
|
||||||
|
},
|
||||||
"viewPdf": {
|
"viewPdf": {
|
||||||
"tags": "view,read,annotate,text,image,highlight,edit",
|
"tags": "view,read,annotate,text,image,highlight,edit",
|
||||||
"title": "View/Edit PDF",
|
"title": "View/Edit PDF",
|
||||||
@ -2068,6 +2073,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rightRail": {
|
||||||
|
"closePdf": "Close PDF",
|
||||||
|
"closeSelected": "Close Selected Files",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"deselectAll": "Deselect All",
|
||||||
|
"selectByNumber": "Select by Page Numbers",
|
||||||
|
"deleteSelected": "Delete Selected Pages",
|
||||||
|
"toggleTheme": "Toggle Theme",
|
||||||
|
"exportAll": "Export PDF",
|
||||||
|
"downloadSelected": "Download Selected Files",
|
||||||
|
"downloadAll": "Download All"
|
||||||
|
},
|
||||||
"removePassword": {
|
"removePassword": {
|
||||||
"title": "Remove Password",
|
"title": "Remove Password",
|
||||||
"desc": "Remove password protection from your PDF document.",
|
"desc": "Remove password protection from your PDF document.",
|
||||||
@ -2089,5 +2106,20 @@
|
|||||||
"results": {
|
"results": {
|
||||||
"title": "Decrypted PDFs"
|
"title": "Decrypted PDFs"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"automation": {
|
||||||
|
"suggested": {
|
||||||
|
"securePdfIngestion": "Secure PDF Ingestion",
|
||||||
|
"securePdfIngestionDesc": "Comprehensive PDF processing workflow that sanitizes documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimizes file size.",
|
||||||
|
"emailPreparation": "Email Preparation",
|
||||||
|
"emailPreparationDesc": "Optimizes PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.",
|
||||||
|
"secureWorkflow": "Security Workflow",
|
||||||
|
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access.",
|
||||||
|
"processImages": "Process Images",
|
||||||
|
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"automate": {
|
||||||
|
"copyToSaved": "Copy to Saved"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
175
frontend/scripts/generate-icons.js
Normal file
175
frontend/scripts/generate-icons.js
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { icons } = require('@iconify-json/material-symbols');
|
||||||
|
const { getIcons } = require('@iconify/utils');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Check for verbose flag
|
||||||
|
const isVerbose = process.argv.includes('--verbose') || process.argv.includes('-v');
|
||||||
|
|
||||||
|
// Logging functions
|
||||||
|
const info = (message) => console.log(message);
|
||||||
|
const debug = (message) => {
|
||||||
|
if (isVerbose) {
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to scan codebase for LocalIcon usage
|
||||||
|
function scanForUsedIcons() {
|
||||||
|
const usedIcons = new Set();
|
||||||
|
const srcDir = path.join(__dirname, '..', 'src');
|
||||||
|
|
||||||
|
info('🔍 Scanning codebase for LocalIcon usage...');
|
||||||
|
|
||||||
|
if (!fs.existsSync(srcDir)) {
|
||||||
|
console.error('❌ Source directory not found:', srcDir);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively scan all .tsx and .ts files
|
||||||
|
function scanDirectory(dir) {
|
||||||
|
const files = fs.readdirSync(dir);
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const filePath = path.join(dir, file);
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
scanDirectory(filePath);
|
||||||
|
} else if (file.endsWith('.tsx') || file.endsWith('.ts')) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...>
|
||||||
|
const localIconMatches = content.match(/<LocalIcon\s+[^>]*icon="([^"]+)"/g);
|
||||||
|
if (localIconMatches) {
|
||||||
|
localIconMatches.forEach(match => {
|
||||||
|
const iconMatch = match.match(/icon="([^"]+)"/);
|
||||||
|
if (iconMatch) {
|
||||||
|
usedIcons.add(iconMatch[1]);
|
||||||
|
debug(` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match old material-symbols-rounded spans: <span className="material-symbols-rounded">icon-name</span>
|
||||||
|
const spanMatches = content.match(/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g);
|
||||||
|
if (spanMatches) {
|
||||||
|
spanMatches.forEach(match => {
|
||||||
|
const iconMatch = match.match(/>([^<]+)<\/span>/);
|
||||||
|
if (iconMatch && iconMatch[1].trim()) {
|
||||||
|
const iconName = iconMatch[1].trim();
|
||||||
|
usedIcons.add(iconName);
|
||||||
|
debug(` Found (legacy): ${iconName} in ${path.relative(srcDir, filePath)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match Icon component usage: <Icon icon="material-symbols:icon-name" ...>
|
||||||
|
const iconMatches = content.match(/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g);
|
||||||
|
if (iconMatches) {
|
||||||
|
iconMatches.forEach(match => {
|
||||||
|
const iconMatch = match.match(/icon="material-symbols:([^"]+)"/);
|
||||||
|
if (iconMatch) {
|
||||||
|
usedIcons.add(iconMatch[1]);
|
||||||
|
debug(` Found (Icon): ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scanDirectory(srcDir);
|
||||||
|
|
||||||
|
const iconArray = Array.from(usedIcons).sort();
|
||||||
|
info(`📋 Found ${iconArray.length} unique icons across codebase`);
|
||||||
|
|
||||||
|
return iconArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect used icons
|
||||||
|
const usedIcons = scanForUsedIcons();
|
||||||
|
|
||||||
|
// Check if we need to regenerate (compare with existing)
|
||||||
|
const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json');
|
||||||
|
let needsRegeneration = true;
|
||||||
|
|
||||||
|
if (fs.existsSync(outputPath)) {
|
||||||
|
try {
|
||||||
|
const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
||||||
|
const existingIcons = Object.keys(existingSet.icons || {}).sort();
|
||||||
|
const currentIcons = [...usedIcons].sort();
|
||||||
|
|
||||||
|
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
|
||||||
|
needsRegeneration = false;
|
||||||
|
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If we can't parse existing file, regenerate
|
||||||
|
needsRegeneration = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsRegeneration) {
|
||||||
|
info('🎉 No regeneration needed!');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`);
|
||||||
|
|
||||||
|
// Extract only our used icons from the full set
|
||||||
|
const extractedIcons = getIcons(icons, usedIcons);
|
||||||
|
|
||||||
|
if (!extractedIcons) {
|
||||||
|
console.error('❌ Failed to extract icons');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for missing icons
|
||||||
|
const extractedIconNames = Object.keys(extractedIcons.icons || {});
|
||||||
|
const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon));
|
||||||
|
|
||||||
|
if (missingIcons.length > 0) {
|
||||||
|
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`);
|
||||||
|
info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output directory
|
||||||
|
const outputDir = path.join(__dirname, '..', 'src', 'assets');
|
||||||
|
if (!fs.existsSync(outputDir)) {
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the extracted icon set to a file (outputPath already defined above)
|
||||||
|
fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2));
|
||||||
|
|
||||||
|
info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`);
|
||||||
|
info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`);
|
||||||
|
info(`💾 Saved to: ${outputPath}`);
|
||||||
|
|
||||||
|
// Generate TypeScript types
|
||||||
|
const typesContent = `// Auto-generated icon types
|
||||||
|
// This file is automatically generated by scripts/generate-icons.js
|
||||||
|
// Do not edit manually - changes will be overwritten
|
||||||
|
|
||||||
|
export type MaterialSymbolIcon = ${usedIcons.map(icon => `'${icon}'`).join(' | ')};
|
||||||
|
|
||||||
|
export interface IconSet {
|
||||||
|
prefix: string;
|
||||||
|
icons: Record<string, any>;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the icon set as the default export with proper typing
|
||||||
|
declare const iconSet: IconSet;
|
||||||
|
export default iconSet;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts');
|
||||||
|
fs.writeFileSync(typesPath, typesContent);
|
||||||
|
|
||||||
|
info(`📝 Generated types: ${typesPath}`);
|
||||||
|
info(`🎉 Icon extraction complete!`);
|
@ -11,6 +11,7 @@ import HomePage from "./pages/HomePage";
|
|||||||
// Import global styles
|
// Import global styles
|
||||||
import "./styles/tailwind.css";
|
import "./styles/tailwind.css";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||||
|
|
||||||
// Loading component for i18next suspense
|
// Loading component for i18next suspense
|
||||||
const LoadingFallback = () => (
|
const LoadingFallback = () => (
|
||||||
@ -38,7 +39,9 @@ export default function App() {
|
|||||||
<FilesModalProvider>
|
<FilesModalProvider>
|
||||||
<ToolWorkflowProvider>
|
<ToolWorkflowProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
|
<RightRailProvider>
|
||||||
<HomePage />
|
<HomePage />
|
||||||
|
</RightRailProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</ToolWorkflowProvider>
|
</ToolWorkflowProvider>
|
||||||
</FilesModalProvider>
|
</FilesModalProvider>
|
||||||
|
@ -21,6 +21,13 @@
|
|||||||
"moduleLicense": "Apache-2.0",
|
"moduleLicense": "Apache-2.0",
|
||||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@atlaskit/pragmatic-drag-and-drop",
|
||||||
|
"moduleUrl": "https://github.com/atlassian/pragmatic-drag-and-drop",
|
||||||
|
"moduleVersion": "1.7.4",
|
||||||
|
"moduleLicense": "Apache-2.0",
|
||||||
|
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@babel/code-frame",
|
"moduleName": "@babel/code-frame",
|
||||||
"moduleUrl": "https://github.com/babel/babel",
|
"moduleUrl": "https://github.com/babel/babel",
|
||||||
@ -59,7 +66,7 @@
|
|||||||
{
|
{
|
||||||
"moduleName": "@babel/parser",
|
"moduleName": "@babel/parser",
|
||||||
"moduleUrl": "https://github.com/babel/babel",
|
"moduleUrl": "https://github.com/babel/babel",
|
||||||
"moduleVersion": "7.27.3",
|
"moduleVersion": "7.28.3",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
},
|
},
|
||||||
@ -87,7 +94,7 @@
|
|||||||
{
|
{
|
||||||
"moduleName": "@babel/types",
|
"moduleName": "@babel/types",
|
||||||
"moduleUrl": "https://github.com/babel/babel",
|
"moduleUrl": "https://github.com/babel/babel",
|
||||||
"moduleVersion": "7.27.3",
|
"moduleVersion": "7.28.2",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
},
|
},
|
||||||
@ -217,6 +224,20 @@
|
|||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@iconify/react",
|
||||||
|
"moduleUrl": "https://github.com/iconify/iconify",
|
||||||
|
"moduleVersion": "6.0.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@iconify/types",
|
||||||
|
"moduleUrl": "https://github.com/iconify/iconify",
|
||||||
|
"moduleVersion": "2.0.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@isaacs/fs-minipass",
|
"moduleName": "@isaacs/fs-minipass",
|
||||||
"moduleUrl": "https://github.com/npm/fs-minipass",
|
"moduleUrl": "https://github.com/npm/fs-minipass",
|
||||||
@ -399,6 +420,20 @@
|
|||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@tanstack/react-virtual",
|
||||||
|
"moduleUrl": "https://github.com/TanStack/virtual",
|
||||||
|
"moduleVersion": "3.13.12",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@tanstack/virtual-core",
|
||||||
|
"moduleUrl": "https://github.com/TanStack/virtual",
|
||||||
|
"moduleVersion": "3.13.12",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@testing-library/dom",
|
"moduleName": "@testing-library/dom",
|
||||||
"moduleUrl": "https://github.com/testing-library/dom-testing-library",
|
"moduleUrl": "https://github.com/testing-library/dom-testing-library",
|
||||||
@ -567,6 +602,13 @@
|
|||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "bind-event-listener",
|
||||||
|
"moduleUrl": "https://github.com/alexreardon/bind-event-listener",
|
||||||
|
"moduleVersion": "3.0.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "brace-expansion",
|
"moduleName": "brace-expansion",
|
||||||
"moduleUrl": "https://github.com/juliangruber/brace-expansion",
|
"moduleUrl": "https://github.com/juliangruber/brace-expansion",
|
||||||
@ -1246,13 +1288,6 @@
|
|||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"moduleName": "material-symbols",
|
|
||||||
"moduleUrl": "https://github.com/marella/material-symbols",
|
|
||||||
"moduleVersion": "0.33.0",
|
|
||||||
"moduleLicense": "Apache-2.0",
|
|
||||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"moduleName": "math-intrinsics",
|
"moduleName": "math-intrinsics",
|
||||||
"moduleUrl": "https://github.com/es-shims/math-intrinsics",
|
"moduleUrl": "https://github.com/es-shims/math-intrinsics",
|
||||||
@ -1494,7 +1529,7 @@
|
|||||||
{
|
{
|
||||||
"moduleName": "postcss",
|
"moduleName": "postcss",
|
||||||
"moduleUrl": "https://github.com/postcss/postcss",
|
"moduleUrl": "https://github.com/postcss/postcss",
|
||||||
"moduleVersion": "8.5.3",
|
"moduleVersion": "8.5.6",
|
||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
},
|
},
|
||||||
@ -1526,6 +1561,13 @@
|
|||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "raf-schd",
|
||||||
|
"moduleUrl": "https://github.com/alexreardon/raf-schd",
|
||||||
|
"moduleVersion": "4.0.3",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "react-dom",
|
"moduleName": "react-dom",
|
||||||
"moduleUrl": "https://github.com/facebook/react",
|
"moduleUrl": "https://github.com/facebook/react",
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
|
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
|
||||||
Stack, Group
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -466,21 +465,6 @@ const FileEditor = ({
|
|||||||
<LoadingOverlay visible={false} />
|
<LoadingOverlay visible={false} />
|
||||||
|
|
||||||
<Box p="md" pt="xl">
|
<Box p="md" pt="xl">
|
||||||
<Group mb="md">
|
|
||||||
{toolMode && (
|
|
||||||
<>
|
|
||||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
|
||||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{showBulkActions && !toolMode && (
|
|
||||||
<>
|
|
||||||
<Button onClick={closeAllFiles} variant="light" color="orange">
|
|
||||||
Close All
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
|
|
||||||
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
|
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||||
@ -573,25 +557,29 @@ const FileEditor = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{status && (
|
{status && (
|
||||||
<Notification
|
<Portal>
|
||||||
color="blue"
|
<Notification
|
||||||
mt="md"
|
color="blue"
|
||||||
onClose={() => setStatus(null)}
|
mt="md"
|
||||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
onClose={() => setStatus(null)}
|
||||||
>
|
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10001 }}
|
||||||
{status}
|
>
|
||||||
</Notification>
|
{status}
|
||||||
|
</Notification>
|
||||||
|
</Portal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Notification
|
<Portal>
|
||||||
color="red"
|
<Notification
|
||||||
mt="md"
|
color="red"
|
||||||
onClose={() => setError(null)}
|
mt="md"
|
||||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
|
onClose={() => setError(null)}
|
||||||
>
|
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
|
||||||
{error}
|
>
|
||||||
</Notification>
|
{error}
|
||||||
|
</Notification>
|
||||||
|
</Portal>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
@ -17,7 +17,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder p={0} h={`calc(${modalHeight} * 0.32 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
|
<Card withBorder p={0} h={`calc(${modalHeight} * 0.32 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<Box bg="blue.6" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
|
<Box bg="gray.4" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
|
||||||
<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>
|
||||||
@ -31,7 +31,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
</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 ? (
|
||||||
@ -43,7 +43,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<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}>
|
||||||
@ -51,7 +51,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
</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">{t('fileManager.fileVersion', 'Version')}</Text>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
@ -64,4 +64,4 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FileInfoCard;
|
export default FileInfoCard;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React 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 FolderIcon from '@mui/icons-material/Folder';
|
import UploadIcon from '@mui/icons-material/Upload';
|
||||||
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 { useFileManagerContext } from '../../contexts/FileManagerContext';
|
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||||
@ -10,7 +10,7 @@ interface FileSourceButtonsProps {
|
|||||||
horizontal?: boolean;
|
horizontal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||||
horizontal = false
|
horizontal = false
|
||||||
}) => {
|
}) => {
|
||||||
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
|
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
|
||||||
@ -44,11 +44,11 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
>
|
>
|
||||||
{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={<FolderIcon />}
|
leftSection={<UploadIcon />}
|
||||||
justify={horizontal ? "center" : "flex-start"}
|
justify={horizontal ? "center" : "flex-start"}
|
||||||
onClick={onLocalFileClick}
|
onClick={onLocalFileClick}
|
||||||
fullWidth={!horizontal}
|
fullWidth={!horizontal}
|
||||||
@ -63,9 +63,9 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{horizontal ? t('fileManager.localFiles', 'Local') : t('fileManager.localFiles', 'Local Files')}
|
{horizontal ? t('fileUpload.uploadFiles', 'Upload') : t('fileUpload.uploadFiles', 'Upload Files')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={buttonProps.variant('drive')}
|
variant={buttonProps.variant('drive')}
|
||||||
leftSection={<CloudIcon />}
|
leftSection={<CloudIcon />}
|
||||||
@ -100,4 +100,4 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FileSourceButtons;
|
export default FileSourceButtons;
|
||||||
|
@ -157,6 +157,7 @@ export default function Workbench() {
|
|||||||
className="flex-1 min-h-0 relative z-10"
|
className="flex-1 min-h-0 relative z-10"
|
||||||
style={{
|
style={{
|
||||||
transition: 'opacity 0.15s ease-in-out',
|
transition: 'opacity 0.15s ease-in-out',
|
||||||
|
marginTop: '1rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderMainContent()}
|
{renderMainContent()}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
|
import { Group, TextInput, Button, Text } from '@mantine/core';
|
||||||
|
|
||||||
interface BulkSelectionPanelProps {
|
interface BulkSelectionPanelProps {
|
||||||
csvInput: string;
|
csvInput: string;
|
||||||
@ -15,7 +15,7 @@ const BulkSelectionPanel = ({
|
|||||||
onUpdatePagesFromCSV,
|
onUpdatePagesFromCSV,
|
||||||
}: BulkSelectionPanelProps) => {
|
}: BulkSelectionPanelProps) => {
|
||||||
return (
|
return (
|
||||||
<Paper p="md" mb="md" withBorder>
|
<>
|
||||||
<Group>
|
<Group>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={csvInput}
|
value={csvInput}
|
||||||
@ -35,7 +35,7 @@ const BulkSelectionPanel = ({
|
|||||||
Selected: {selectedPages.length} pages
|
Selected: {selectedPages.length} pages
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
|
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
|
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||||
|
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 './PageEditor.module.css';
|
import styles from './PageEditor.module.css';
|
||||||
import { useFileContext } from '../../contexts/FileContext';
|
import { useFileContext } from '../../contexts/FileContext';
|
||||||
|
|
||||||
@ -15,7 +18,7 @@ interface FileItem {
|
|||||||
pageCount: number;
|
pageCount: number;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
size: number;
|
size: number;
|
||||||
splitBefore?: boolean;
|
modifiedAt?: number | string | Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileThumbnailProps {
|
interface FileThumbnailProps {
|
||||||
@ -29,6 +32,7 @@ interface FileThumbnailProps {
|
|||||||
onViewFile: (fileId: string) => void;
|
onViewFile: (fileId: string) => void;
|
||||||
onSetStatus: (status: string) => void;
|
onSetStatus: (status: string) => void;
|
||||||
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void;
|
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void;
|
||||||
|
onDownloadFile?: (fileId: string) => void;
|
||||||
toolMode?: boolean;
|
toolMode?: boolean;
|
||||||
isSupported?: boolean;
|
isSupported?: boolean;
|
||||||
}
|
}
|
||||||
@ -36,36 +40,93 @@ interface FileThumbnailProps {
|
|||||||
const FileThumbnail = ({
|
const FileThumbnail = ({
|
||||||
file,
|
file,
|
||||||
index,
|
index,
|
||||||
totalFiles,
|
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
selectionMode,
|
|
||||||
onToggleFile,
|
onToggleFile,
|
||||||
onDeleteFile,
|
onDeleteFile,
|
||||||
onViewFile,
|
onViewFile,
|
||||||
onSetStatus,
|
onSetStatus,
|
||||||
onReorderFiles,
|
onReorderFiles,
|
||||||
toolMode = false,
|
onDownloadFile,
|
||||||
isSupported = true,
|
isSupported = true,
|
||||||
}: FileThumbnailProps) => {
|
}: FileThumbnailProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
||||||
|
|
||||||
// Drag and drop state
|
// ---- Drag state ----
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [actionsWidth, setActionsWidth] = useState<number | undefined>(undefined);
|
||||||
|
const [showActions, setShowActions] = useState(false);
|
||||||
|
|
||||||
// Find the actual File object that corresponds to this FileItem
|
// Resolve the actual File object for pin/unpin operations
|
||||||
const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size);
|
const actualFile = useMemo(() => {
|
||||||
|
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
|
||||||
|
}, [activeFiles, file.name, file.size]);
|
||||||
|
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
const downloadSelectedFile = useCallback(() => {
|
||||||
|
// Prefer parent-provided handler if available
|
||||||
|
if (typeof onDownloadFile === 'function') {
|
||||||
|
onDownloadFile(file.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: attempt to download using the File object if provided
|
||||||
|
const maybeFile = (file as unknown as { file?: File }).file;
|
||||||
|
if (maybeFile instanceof File) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(maybeFile);
|
||||||
|
link.download = maybeFile.name || file.name || 'download';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't find a way to download, surface a status message
|
||||||
|
onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item');
|
||||||
|
}, [file, onDownloadFile, onSetStatus, t]);
|
||||||
|
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
|
||||||
|
// ---- Selection ----
|
||||||
|
const isSelected = selectedFiles.includes(file.id);
|
||||||
|
|
||||||
|
// ---- Meta formatting ----
|
||||||
|
const prettySize = useMemo(() => {
|
||||||
|
const bytes = file.size ?? 0;
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
};
|
}, [file.size]);
|
||||||
|
|
||||||
// Setup drag and drop using @atlaskit/pragmatic-drag-and-drop
|
const extUpper = useMemo(() => {
|
||||||
|
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
|
||||||
|
return (m?.[1] || '').toUpperCase();
|
||||||
|
}, [file.name]);
|
||||||
|
|
||||||
|
const pageLabel = useMemo(
|
||||||
|
() =>
|
||||||
|
file.pageCount > 0
|
||||||
|
? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}`
|
||||||
|
: '',
|
||||||
|
[file.pageCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateLabel = useMemo(() => {
|
||||||
|
const d =
|
||||||
|
file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(d);
|
||||||
|
}, [file.modifiedAt]);
|
||||||
|
|
||||||
|
// ---- Drag & drop wiring ----
|
||||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|
||||||
@ -111,254 +172,234 @@ const FileThumbnail = ({
|
|||||||
dragCleanup();
|
dragCleanup();
|
||||||
dropCleanup();
|
dropCleanup();
|
||||||
};
|
};
|
||||||
}, [file.id, file.name, selectionMode, selectedFiles, onReorderFiles]);
|
}, [file.id, file.name, selectedFiles, onReorderFiles]);
|
||||||
|
|
||||||
|
// Update dropdown width on resize
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => {
|
||||||
|
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
return () => window.removeEventListener('resize', update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close the actions dropdown when hovering outside this file card (and its dropdown)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showActions) return;
|
||||||
|
|
||||||
|
const isInsideCard = (target: EventTarget | null) => {
|
||||||
|
const container = dragElementRef.current;
|
||||||
|
if (!container) return false;
|
||||||
|
return target instanceof Node && container.contains(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isInsideCard(e.target)) {
|
||||||
|
setShowActions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
|
// On touch devices, close if the touch target is outside the card
|
||||||
|
if (!isInsideCard(e.target)) {
|
||||||
|
setShowActions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('touchstart', handleTouchStart);
|
||||||
|
};
|
||||||
|
}, [showActions]);
|
||||||
|
|
||||||
|
// ---- Card interactions ----
|
||||||
|
const handleCardClick = () => {
|
||||||
|
if (!isSupported) return;
|
||||||
|
onToggleFile(file.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={fileElementRef}
|
ref={fileElementRef}
|
||||||
data-file-id={file.id}
|
data-file-id={file.id}
|
||||||
data-testid="file-thumbnail"
|
data-testid="file-thumbnail"
|
||||||
className={`
|
data-selected={isSelected}
|
||||||
${styles.pageContainer}
|
data-supported={isSupported}
|
||||||
!rounded-lg
|
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||||
cursor-grab
|
|
||||||
select-none
|
|
||||||
w-[20rem]
|
|
||||||
h-[24rem]
|
|
||||||
flex flex-col items-center justify-center
|
|
||||||
flex-shrink-0
|
|
||||||
shadow-sm
|
|
||||||
hover:shadow-md
|
|
||||||
transition-all
|
|
||||||
relative
|
|
||||||
${selectionMode
|
|
||||||
? 'bg-white hover:bg-gray-50'
|
|
||||||
: 'bg-white hover:bg-gray-50'}
|
|
||||||
${isDragging ? 'opacity-50 scale-95' : ''}
|
|
||||||
`}
|
|
||||||
style={{
|
style={{
|
||||||
opacity: isSupported ? (isDragging ? 0.5 : 1) : 0.5,
|
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
|
||||||
filter: isSupported ? 'none' : 'grayscale(50%)'
|
filter: isSupported ? 'none' : 'grayscale(50%)',
|
||||||
}}
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="listitem"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onClick={handleCardClick}
|
||||||
>
|
>
|
||||||
{selectionMode && (
|
{/* Header bar */}
|
||||||
<div
|
<div
|
||||||
className={styles.checkboxContainer}
|
className={`${styles.header} ${
|
||||||
data-testid="file-thumbnail-checkbox"
|
isSelected ? styles.headerSelected : styles.headerResting
|
||||||
style={{
|
}`}
|
||||||
position: 'absolute',
|
>
|
||||||
top: 8,
|
{/* Logo/checkbox area */}
|
||||||
right: 8,
|
<div className={styles.logoMark}>
|
||||||
zIndex: 4,
|
{isSupported ? (
|
||||||
backgroundColor: 'white',
|
<CheckboxIndicator
|
||||||
borderRadius: '4px',
|
checked={isSelected}
|
||||||
padding: '2px',
|
onChange={() => onToggleFile(file.id)}
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
color="var(--checkbox-checked-bg)"
|
||||||
pointerEvents: 'auto'
|
/>
|
||||||
}}
|
) : (
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
<div className={styles.unsupportedPill}>
|
||||||
onDragStart={(e) => {
|
<span>
|
||||||
e.preventDefault();
|
{t('unsupported', 'Unsupported')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Centered index */}
|
||||||
|
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kebab menu */}
|
||||||
|
<ActionIcon
|
||||||
|
aria-label={t('moreOptions', 'More options')}
|
||||||
|
variant="subtle"
|
||||||
|
className={styles.kebab}
|
||||||
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setShowActions((v) => !v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<MoreVertIcon fontSize="small" />
|
||||||
checked={selectedFiles.includes(file.id)}
|
</ActionIcon>
|
||||||
onChange={(event) => {
|
</div>
|
||||||
event.stopPropagation();
|
|
||||||
if (isSupported) {
|
{/* Actions overlay */}
|
||||||
onToggleFile(file.id);
|
{showActions && (
|
||||||
|
<div
|
||||||
|
className={styles.actionsOverlay}
|
||||||
|
style={{ width: actionsWidth }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={styles.actionRow}
|
||||||
|
onClick={() => {
|
||||||
|
if (actualFile) {
|
||||||
|
if (isPinned) {
|
||||||
|
unpinFile(actualFile);
|
||||||
|
onSetStatus?.(`Unpinned ${file.name}`);
|
||||||
|
} else {
|
||||||
|
pinFile(actualFile);
|
||||||
|
onSetStatus?.(`Pinned ${file.name}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
setShowActions(false);
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
>
|
||||||
disabled={!isSupported}
|
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||||
size="sm"
|
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
|
||||||
/>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={styles.actionRow}
|
||||||
|
onClick={() => { downloadSelectedFile(); setShowActions(false); }}
|
||||||
|
>
|
||||||
|
<DownloadOutlinedIcon fontSize="small" />
|
||||||
|
<span>{t('download', 'Download')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={styles.actionsDivider} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||||
|
onClick={() => {
|
||||||
|
onDeleteFile(file.id);
|
||||||
|
onSetStatus(`Deleted ${file.name}`);
|
||||||
|
setShowActions(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
<span>{t('delete', 'Delete')}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File content area */}
|
{/* Title + meta line */}
|
||||||
<div className="file-container w-[90%] h-[80%] relative">
|
<div
|
||||||
{/* Stacked file effect - multiple shadows to simulate pages */}
|
style={{
|
||||||
<div
|
padding: '0.5rem',
|
||||||
style={{
|
textAlign: 'center',
|
||||||
width: '100%',
|
background: 'var(--file-card-bg)',
|
||||||
height: '100%',
|
marginTop: '0.5rem',
|
||||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
marginBottom: '0.5rem',
|
||||||
borderRadius: 6,
|
}}>
|
||||||
border: '1px solid var(--mantine-color-gray-3)',
|
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
|
||||||
padding: 4,
|
{file.name}
|
||||||
display: 'flex',
|
</Text>
|
||||||
alignItems: 'center',
|
<Text
|
||||||
justifyContent: 'center',
|
size="sm"
|
||||||
position: 'relative',
|
c="dimmed"
|
||||||
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
|
className={styles.meta}
|
||||||
}}
|
lineClamp={3}
|
||||||
|
title={`${extUpper || 'FILE'} • ${prettySize}`}
|
||||||
>
|
>
|
||||||
|
{/* e.g., Jan 29, 2025 - PDF file - 3 Pages */}
|
||||||
|
{dateLabel}
|
||||||
|
{extUpper ? ` - ${extUpper} file` : ''}
|
||||||
|
{pageLabel ? ` - ${pageLabel}` : ''}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview area */}
|
||||||
|
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
|
||||||
|
<div className={styles.previewPaper}>
|
||||||
<img
|
<img
|
||||||
src={file.thumbnail}
|
src={file.thumbnail}
|
||||||
alt={file.name}
|
alt={file.name}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// Hide broken image if blob URL was revoked
|
const img = e.currentTarget;
|
||||||
const img = e.target as HTMLImageElement;
|
|
||||||
img.style.display = 'none';
|
img.style.display = 'none';
|
||||||
|
img.parentElement?.setAttribute('data-thumb-missing', 'true');
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '80%',
|
||||||
maxHeight: '100%',
|
maxHeight: '80%',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
borderRadius: 2,
|
borderRadius: 0,
|
||||||
|
background: '#ffffff',
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
display: 'block',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginRight: 'auto',
|
||||||
|
alignSelf: 'start'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page count badge - only show for PDFs */}
|
{/* Pin indicator (bottom-left) */}
|
||||||
{file.pageCount > 0 && (
|
{isPinned && (
|
||||||
<Badge
|
<span className={styles.pinIndicator} aria-hidden>
|
||||||
size="sm"
|
<PushPinIcon fontSize="small" />
|
||||||
variant="filled"
|
</span>
|
||||||
color="blue"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
left: 8,
|
|
||||||
zIndex: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Unsupported badge */}
|
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
||||||
{!isSupported && (
|
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||||
<Badge
|
<DragIndicatorIcon fontSize="small" />
|
||||||
size="sm"
|
</span>
|
||||||
variant="filled"
|
|
||||||
color="orange"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
right: selectionMode ? 48 : 8, // Avoid overlap with checkbox
|
|
||||||
zIndex: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("fileManager.unsupported", "Unsupported")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File name overlay */}
|
|
||||||
<Text
|
|
||||||
className={styles.pageNumber}
|
|
||||||
size="xs"
|
|
||||||
fw={500}
|
|
||||||
c="white"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 5,
|
|
||||||
left: 5,
|
|
||||||
right: 5,
|
|
||||||
background: 'rgba(0, 0, 0, 0.8)',
|
|
||||||
padding: '4px 6px',
|
|
||||||
borderRadius: 4,
|
|
||||||
zIndex: 2,
|
|
||||||
opacity: 0,
|
|
||||||
transition: 'opacity 0.2s ease-in-out',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
overflow: 'hidden',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{file.name}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Hover controls */}
|
|
||||||
<div
|
|
||||||
className={styles.pageHoverControls}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
background: 'rgba(0, 0, 0, 0.8)',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: 20,
|
|
||||||
opacity: 0,
|
|
||||||
transition: 'opacity 0.2s ease-in-out',
|
|
||||||
zIndex: 3,
|
|
||||||
display: 'flex',
|
|
||||||
gap: '8px',
|
|
||||||
alignItems: 'center',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
|
|
||||||
{actualFile && (
|
|
||||||
<Tooltip label={isFilePinned(actualFile) ? "Unpin File" : "Pin File"}>
|
|
||||||
<ActionIcon
|
|
||||||
size="md"
|
|
||||||
variant="subtle"
|
|
||||||
c={isFilePinned(actualFile) ? "yellow" : "white"}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isFilePinned(actualFile)) {
|
|
||||||
unpinFile(actualFile);
|
|
||||||
onSetStatus(`Unpinned ${file.name}`);
|
|
||||||
} else {
|
|
||||||
pinFile(actualFile);
|
|
||||||
onSetStatus(`Pinned ${file.name}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isFilePinned(actualFile) ? (
|
|
||||||
<PushPinIcon style={{ fontSize: 20 }} />
|
|
||||||
) : (
|
|
||||||
<PushPinOutlinedIcon style={{ fontSize: 20 }} />
|
|
||||||
)}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tooltip label="Close File">
|
|
||||||
<ActionIcon
|
|
||||||
size="md"
|
|
||||||
variant="subtle"
|
|
||||||
c="orange"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDeleteFile(file.id);
|
|
||||||
onSetStatus(`Closed ${file.name}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloseIcon style={{ fontSize: 20 }} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DragIndicatorIcon
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 4,
|
|
||||||
right: 4,
|
|
||||||
color: 'rgba(0,0,0,0.3)',
|
|
||||||
fontSize: 16,
|
|
||||||
zIndex: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File info */}
|
|
||||||
<div className="w-full px-4 py-2 text-center">
|
|
||||||
<Text size="sm" fw={500} truncate>
|
|
||||||
{file.name}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FileThumbnail;
|
export default React.memo(FileThumbnail);
|
||||||
|
@ -1,67 +1,265 @@
|
|||||||
/* Page container hover effects - optimized for smooth scrolling */
|
/* =========================
|
||||||
.pageContainer {
|
NEW styles for card UI
|
||||||
transition: transform 0.2s ease-in-out;
|
========================= */
|
||||||
/* Enable hardware acceleration for smoother scrolling */
|
|
||||||
will-change: transform;
|
.card {
|
||||||
transform: translateZ(0);
|
background: var(--file-card-bg);
|
||||||
backface-visibility: hidden;
|
border-radius: 0.0625rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.card[data-selected="true"] {
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* While dragging */
|
||||||
|
.card.dragging,
|
||||||
|
.card:global(.dragging) {
|
||||||
|
outline: 1px solid var(--border-strong);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Header -------- */
|
||||||
|
.header {
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 0.0625rem 0.0625rem 0 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px 1fr 44px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 6px;
|
||||||
|
user-select: none;
|
||||||
|
background: var(--bg-toolbar);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
.headerResting {
|
||||||
|
background: #3B4B6E; /* dark blue for unselected in light mode */
|
||||||
|
color: #FFFFFF;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
.headerSelected {
|
||||||
|
background: var(--header-selected-bg);
|
||||||
|
color: var(--header-selected-fg);
|
||||||
|
border-bottom: 1px solid var(--header-selected-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected border color in light mode */
|
||||||
|
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||||
|
outline-color: var(--card-selected-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reserve space for checkbox instead of logo */
|
||||||
|
.logoMark {
|
||||||
|
margin-left: 8px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerIndex {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab {
|
||||||
|
justify-self: end;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu dropdown */
|
||||||
|
.menuDropdown {
|
||||||
|
min-width: 210px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Title / Meta -------- */
|
||||||
|
.title {
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- Preview area -------- */
|
||||||
|
.previewBox {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--file-card-bg);
|
||||||
|
}
|
||||||
|
.previewPaper {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - 6px);
|
||||||
|
min-height: 9rem;
|
||||||
|
justify-content: center;
|
||||||
|
display: grid;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--file-card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumbnail fallback */
|
||||||
|
.previewPaper[data-thumb-missing="true"]::after {
|
||||||
|
content: "No preview";
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600; font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag handle grip */
|
||||||
|
.dragHandle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
right: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
z-index: 1;
|
||||||
|
cursor: grab;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.card,
|
||||||
|
.menuDropdown {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
DARK MODE OVERRIDES
|
||||||
|
========================= */
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .card {
|
||||||
|
outline-color: #3A4047; /* deselected stroke */
|
||||||
|
}
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] {
|
||||||
|
outline-color: #4B525A; /* selected stroke (subtle grey) */
|
||||||
|
}
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .headerResting {
|
||||||
|
background: #1F2329; /* requested default unselected color */
|
||||||
|
color: var(--tool-header-text); /* #D0D6DC */
|
||||||
|
border-bottom-color: var(--tool-header-border); /* #3A4047 */
|
||||||
|
}
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .headerSelected {
|
||||||
|
background: var(--tool-header-border); /* #3A4047 */
|
||||||
|
color: var(--tool-header-text); /* #D0D6DC */
|
||||||
|
border-bottom-color: var(--tool-header-border);
|
||||||
|
}
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .title {
|
||||||
|
color: #D0D6DC; /* title text */
|
||||||
|
}
|
||||||
|
:global([data-mantine-color-scheme="dark"]) .meta {
|
||||||
|
color: #6B7280; /* subtitle text */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode selected header stroke override */
|
||||||
|
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||||
|
outline-color: #3B4B6E;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
(Optional) legacy styles from your
|
||||||
|
previous component kept here to
|
||||||
|
avoid breaking other imports.
|
||||||
|
They are not used by the new card.
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
.pageContainer {
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
will-change: transform;
|
||||||
|
transform: translateZ(0);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
.pageContainer:hover { transform: scale(1.02) translateZ(0); }
|
||||||
|
.pageContainer:hover .pageNumber { opacity: 1 !important; }
|
||||||
|
.pageContainer:hover .pageHoverControls { opacity: 1 !important; }
|
||||||
|
.checkboxContainer { transform: none !important; transition: none !important; }
|
||||||
|
|
||||||
|
.pageMoveAnimation { transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); }
|
||||||
|
.pageMoving { z-index: 10; transform: scale(1.05); box-shadow: 0 10px 30px rgba(0,0,0,0.3); }
|
||||||
|
|
||||||
|
.multiDragIndicator {
|
||||||
|
position: fixed;
|
||||||
|
background: rgba(59, 130, 246, 0.9);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
|
||||||
|
.pulse { animation: pulse 1s infinite; }
|
||||||
|
|
||||||
|
.actionsOverlay {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 44px; /* just below header */
|
||||||
|
background: var(--bg-toolbar);
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
z-index: 20;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideDown 140ms ease-out;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
@keyframes slideDown { from { transform: translateY(-8px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }
|
||||||
|
|
||||||
|
.actionRow {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.actionRow:hover { background: var(--hover-bg); }
|
||||||
|
.actionDanger { color: var(--text-brand-accent); }
|
||||||
|
.actionsDivider { height: 1px; background: var(--border-default); margin: 4px 0; }
|
||||||
|
|
||||||
|
.pinIndicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
left: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
color: rgba(0, 0, 0, 0.35); /* match drag handle color */
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageContainer:hover {
|
.unsupportedPill {
|
||||||
transform: scale(1.02) translateZ(0);
|
margin-left: 1.75rem;
|
||||||
}
|
background: #6B7280;
|
||||||
|
|
||||||
.pageContainer:hover .pageNumber {
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageContainer:hover .pageHoverControls {
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Checkbox container - prevent transform inheritance */
|
|
||||||
.checkboxContainer {
|
|
||||||
transform: none !important;
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page movement animations */
|
|
||||||
.pageMoveAnimation {
|
|
||||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageMoving {
|
|
||||||
z-index: 10;
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Multi-page drag indicator */
|
|
||||||
.multiDragIndicator {
|
|
||||||
position: fixed;
|
|
||||||
background: rgba(59, 130, 246, 0.9);
|
|
||||||
color: white;
|
color: white;
|
||||||
padding: 8px 12px;
|
padding: 4px 8px;
|
||||||
border-radius: 20px;
|
border-radius: 12px;
|
||||||
font-size: 12px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
pointer-events: none;
|
display: flex;
|
||||||
z-index: 1000;
|
align-items: center;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
justify-content: center;
|
||||||
transform: translate(-50%, -50%);
|
min-width: 80px;
|
||||||
backdrop-filter: blur(4px);
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulse {
|
|
||||||
animation: pulse 1s infinite;
|
|
||||||
}
|
|
@ -1,14 +1,13 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
|
Button, Text, Center, Box,
|
||||||
Notification, TextInput, LoadingOverlay, Modal, Alert,
|
Notification, TextInput, LoadingOverlay, Modal, Alert,
|
||||||
Stack, Group
|
Stack, Group, Portal
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
|
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
|
||||||
import { ModeType } from "../../contexts/NavigationContext";
|
import { ModeType } from "../../contexts/NavigationContext";
|
||||||
import { PDFDocument, PDFPage } from "../../types/pageEditor";
|
import { PDFDocument, PDFPage } from "../../types/pageEditor";
|
||||||
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
|
|
||||||
import { useUndoRedo } from "../../hooks/useUndoRedo";
|
import { useUndoRedo } from "../../hooks/useUndoRedo";
|
||||||
import {
|
import {
|
||||||
RotatePagesCommand,
|
RotatePagesCommand,
|
||||||
@ -56,7 +55,6 @@ export interface PageEditorProps {
|
|||||||
const PageEditor = ({
|
const PageEditor = ({
|
||||||
onFunctionsReady,
|
onFunctionsReady,
|
||||||
}: PageEditorProps) => {
|
}: PageEditorProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Use split contexts to prevent re-renders
|
// Use split contexts to prevent re-renders
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
@ -241,19 +239,26 @@ const PageEditor = ({
|
|||||||
const [exportLoading, setExportLoading] = useState(false);
|
const [exportLoading, setExportLoading] = useState(false);
|
||||||
const [showExportModal, setShowExportModal] = useState(false);
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
|
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
|
||||||
|
const [exportSelectedOnly, setExportSelectedOnly] = useState<boolean>(false);
|
||||||
|
|
||||||
// Animation state
|
// Animation state
|
||||||
const [movingPage, setMovingPage] = useState<number | null>(null);
|
const [movingPage, setMovingPage] = useState<number | null>(null);
|
||||||
const [pagePositions, setPagePositions] = useState<Map<string, { x: number; y: number }>>(new Map());
|
|
||||||
const [isAnimating, setIsAnimating] = useState(false);
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
const pageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
||||||
const fileInputRef = useRef<() => void>(null);
|
|
||||||
|
|
||||||
// Undo/Redo system
|
// Undo/Redo system
|
||||||
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
|
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
|
||||||
|
|
||||||
|
// Track whether the user has manually edited the filename to avoid auto-overwrites
|
||||||
|
const userEditedFilename = useRef(false);
|
||||||
|
|
||||||
|
// Reset user edit flag when the active files change, so defaults can be applied for new docs
|
||||||
|
useEffect(() => {
|
||||||
|
userEditedFilename.current = false;
|
||||||
|
}, [filesSignature]);
|
||||||
|
|
||||||
// Set initial filename when document changes - use stable signature
|
// Set initial filename when document changes - use stable signature
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (userEditedFilename.current) return; // Do not overwrite user-typed filename
|
||||||
if (mergedPdfDocument) {
|
if (mergedPdfDocument) {
|
||||||
if (activeFileIds.length === 1 && primaryFileId) {
|
if (activeFileIds.length === 1 && primaryFileId) {
|
||||||
const record = selectors.getFileRecord(primaryFileId);
|
const record = selectors.getFileRecord(primaryFileId);
|
||||||
@ -838,14 +843,18 @@ const PageEditor = ({
|
|||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
if (!displayDocument) return;
|
if (!displayDocument) return;
|
||||||
|
|
||||||
const pagesToDelete = selectionMode
|
const hasSelectedPages = selectedPageNumbers.length > 0;
|
||||||
? selectedPageNumbers.map(pageNum => {
|
|
||||||
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
|
const pagesToDelete = (selectionMode || hasSelectedPages)
|
||||||
return page?.id || '';
|
? selectedPageNumbers
|
||||||
}).filter(id => id)
|
.map(pageNum => {
|
||||||
|
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
|
||||||
|
return page?.id || '';
|
||||||
|
})
|
||||||
|
.filter(id => id)
|
||||||
: displayDocument.pages.map(p => p.id);
|
: displayDocument.pages.map(p => p.id);
|
||||||
|
|
||||||
if (selectionMode && selectedPageNumbers.length === 0) return;
|
if ((selectionMode || hasSelectedPages) && selectedPageNumbers.length === 0) return;
|
||||||
|
|
||||||
const command = new DeletePagesCommand(
|
const command = new DeletePagesCommand(
|
||||||
displayDocument,
|
displayDocument,
|
||||||
@ -857,7 +866,7 @@ const PageEditor = ({
|
|||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
actions.setSelectedPages([]);
|
actions.setSelectedPages([]);
|
||||||
}
|
}
|
||||||
const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length;
|
const pageCount = (selectionMode || hasSelectedPages) ? selectedPageNumbers.length : displayDocument.pages.length;
|
||||||
setStatus(`Deleted ${pageCount} pages`);
|
setStatus(`Deleted ${pageCount} pages`);
|
||||||
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, actions]);
|
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, actions]);
|
||||||
|
|
||||||
@ -885,49 +894,52 @@ const PageEditor = ({
|
|||||||
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]);
|
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]);
|
||||||
|
|
||||||
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
|
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
|
||||||
if (!mergedPdfDocument) return;
|
const doc = editedDocument || mergedPdfDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
|
||||||
// Convert page numbers to page IDs for export service
|
// Convert page numbers to page IDs for export service
|
||||||
const exportPageIds = selectedOnly
|
const exportPageIds = selectedOnly
|
||||||
? selectedPageNumbers.map(pageNum => {
|
? selectedPageNumbers.map(pageNum => {
|
||||||
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
|
const page = doc.pages.find(p => p.pageNumber === pageNum);
|
||||||
return page?.id || '';
|
return page?.id || '';
|
||||||
}).filter(id => id)
|
}).filter(id => id)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const preview = pdfExportService.getExportInfo(doc, exportPageIds, selectedOnly);
|
||||||
const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly);
|
|
||||||
setExportPreview(preview);
|
setExportPreview(preview);
|
||||||
|
setExportSelectedOnly(selectedOnly);
|
||||||
setShowExportModal(true);
|
setShowExportModal(true);
|
||||||
}, [mergedPdfDocument, selectedPageNumbers]);
|
}, [editedDocument, mergedPdfDocument, selectedPageNumbers]);
|
||||||
|
|
||||||
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
|
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
|
||||||
if (!mergedPdfDocument) return;
|
const doc = editedDocument || mergedPdfDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
|
||||||
setExportLoading(true);
|
setExportLoading(true);
|
||||||
try {
|
try {
|
||||||
// Convert page numbers to page IDs for export service
|
// Convert page numbers to page IDs for export service
|
||||||
const exportPageIds = selectedOnly
|
const exportPageIds = selectedOnly
|
||||||
? selectedPageNumbers.map(pageNum => {
|
? selectedPageNumbers.map(pageNum => {
|
||||||
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
|
const page = doc.pages.find(p => p.pageNumber === pageNum);
|
||||||
return page?.id || '';
|
return page?.id || '';
|
||||||
}).filter(id => id)
|
}).filter(id => id)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|
||||||
const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly);
|
const errors = pdfExportService.validateExport(doc, exportPageIds, selectedOnly);
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
setStatus(errors.join(', '));
|
setStatus(errors.join(', '));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasSplitMarkers = mergedPdfDocument.pages.some(page => page.splitBefore);
|
const hasSplitMarkers = doc.pages.some(page => page.splitBefore);
|
||||||
|
|
||||||
if (hasSplitMarkers) {
|
if (hasSplitMarkers) {
|
||||||
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
|
const result = await pdfExportService.exportPDF(doc, exportPageIds, {
|
||||||
selectedOnly,
|
selectedOnly,
|
||||||
filename,
|
filename,
|
||||||
splitDocuments: true
|
splitDocuments: true,
|
||||||
|
appendSuffix: false
|
||||||
}) as { blobs: Blob[]; filenames: string[] };
|
}) as { blobs: Blob[]; filenames: string[] };
|
||||||
|
|
||||||
result.blobs.forEach((blob, index) => {
|
result.blobs.forEach((blob, index) => {
|
||||||
@ -938,9 +950,10 @@ const PageEditor = ({
|
|||||||
|
|
||||||
setStatus(`Exported ${result.blobs.length} split documents`);
|
setStatus(`Exported ${result.blobs.length} split documents`);
|
||||||
} else {
|
} else {
|
||||||
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
|
const result = await pdfExportService.exportPDF(doc, exportPageIds, {
|
||||||
selectedOnly,
|
selectedOnly,
|
||||||
filename
|
filename,
|
||||||
|
appendSuffix: false
|
||||||
}) as { blob: Blob; filename: string };
|
}) as { blob: Blob; filename: string };
|
||||||
|
|
||||||
pdfExportService.downloadFile(result.blob, result.filename);
|
pdfExportService.downloadFile(result.blob, result.filename);
|
||||||
@ -953,7 +966,7 @@ const PageEditor = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setExportLoading(false);
|
setExportLoading(false);
|
||||||
}
|
}
|
||||||
}, [mergedPdfDocument, selectedPageNumbers, filename]);
|
}, [editedDocument, mergedPdfDocument, selectedPageNumbers, filename]);
|
||||||
|
|
||||||
const handleUndo = useCallback(() => {
|
const handleUndo = useCallback(() => {
|
||||||
if (undo()) {
|
if (undo()) {
|
||||||
@ -1240,59 +1253,13 @@ const PageEditor = ({
|
|||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group mb="md">
|
|
||||||
<TextInput
|
<TextInput
|
||||||
|
label="Filename"
|
||||||
value={filename}
|
value={filename}
|
||||||
onChange={(e) => setFilename(e.target.value)}
|
onChange={(e) => setFilename(e.target.value)}
|
||||||
placeholder="Enter filename"
|
placeholder="Enter filename"
|
||||||
style={{ minWidth: 200 }}
|
style={{ minWidth: 200, maxWidth: 200, marginLeft: "1rem"}}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
onClick={toggleSelectionMode}
|
|
||||||
variant={selectionMode ? "filled" : "outline"}
|
|
||||||
color={selectionMode ? "blue" : "gray"}
|
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
...(selectionMode && {
|
|
||||||
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectionMode ? "Exit Selection" : "Select Pages"}
|
|
||||||
</Button>
|
|
||||||
{selectionMode && (
|
|
||||||
<>
|
|
||||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
|
||||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
{/* Apply Changes Button */}
|
|
||||||
{hasUnsavedChanges && (
|
|
||||||
<Button
|
|
||||||
onClick={applyChanges}
|
|
||||||
color="green"
|
|
||||||
variant="filled"
|
|
||||||
style={{ marginLeft: 'auto' }}
|
|
||||||
>
|
|
||||||
Apply Changes
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{selectionMode && (
|
|
||||||
<BulkSelectionPanel
|
|
||||||
csvInput={csvInput}
|
|
||||||
setCsvInput={setCsvInput}
|
|
||||||
selectedPages={selectedPageNumbers}
|
|
||||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<DragDropGrid
|
<DragDropGrid
|
||||||
@ -1386,8 +1353,7 @@ const PageEditor = ({
|
|||||||
loading={exportLoading}
|
loading={exportLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowExportModal(false);
|
setShowExportModal(false);
|
||||||
const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0);
|
handleExport(exportSelectedOnly);
|
||||||
handleExport(selectedOnly);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Export PDF
|
Export PDF
|
||||||
@ -1446,14 +1412,16 @@ const PageEditor = ({
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{status && (
|
{status && (
|
||||||
|
<Portal>
|
||||||
<Notification
|
<Notification
|
||||||
color="blue"
|
color="blue"
|
||||||
mt="md"
|
mt="md"
|
||||||
onClose={() => setStatus(null)}
|
onClose={() => setStatus(null)}
|
||||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10000 }}
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</Notification>
|
</Notification>
|
||||||
|
</Portal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
@ -2,16 +2,12 @@ import React from "react";
|
|||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Paper
|
|
||||||
} from "@mantine/core";
|
} 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";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
|
||||||
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 CloseIcon from "@mui/icons-material/Close";
|
|
||||||
|
|
||||||
interface PageEditorControlsProps {
|
interface PageEditorControlsProps {
|
||||||
// Close/Reset functions
|
// Close/Reset functions
|
||||||
@ -39,17 +35,12 @@ interface PageEditorControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PageEditorControls = ({
|
const PageEditorControls = ({
|
||||||
onClosePdf,
|
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
onRotate,
|
onRotate,
|
||||||
onDelete,
|
|
||||||
onSplit,
|
onSplit,
|
||||||
onExportSelected,
|
|
||||||
onExportAll,
|
|
||||||
exportLoading,
|
|
||||||
selectionMode,
|
selectionMode,
|
||||||
selectedPages
|
selectedPages
|
||||||
}: PageEditorControlsProps) => {
|
}: PageEditorControlsProps) => {
|
||||||
@ -57,9 +48,9 @@ const PageEditorControls = ({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: 0,
|
||||||
bottom: '20px',
|
right: 0,
|
||||||
transform: 'translateX(-50%)',
|
bottom: 0,
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@ -67,34 +58,28 @@ const PageEditorControls = ({
|
|||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Paper
|
<div
|
||||||
radius="xl"
|
|
||||||
shadow="lg"
|
|
||||||
p={16}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
borderRadius: 32,
|
borderTopLeftRadius: 16,
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
borderTopRightRadius: 16,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
boxShadow: '0 -2px 8px rgba(0,0,0,0.04)',
|
||||||
|
backgroundColor: 'var(--bg-toolbar)',
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
borderRadius: '16px 16px 0 0',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
minWidth: 400,
|
minWidth: 420,
|
||||||
justifyContent: 'center'
|
maxWidth: 700,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: "1rem",
|
||||||
|
paddingBottom: "2rem"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Close PDF */}
|
|
||||||
<Tooltip label="Close PDF">
|
|
||||||
<ActionIcon
|
|
||||||
onClick={onClosePdf}
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
|
||||||
|
|
||||||
{/* Undo/Redo */}
|
{/* Undo/Redo */}
|
||||||
<Tooltip label="Undo">
|
<Tooltip label="Undo">
|
||||||
@ -133,17 +118,6 @@ const PageEditorControls = ({
|
|||||||
<RotateRightIcon />
|
<RotateRightIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={selectionMode ? "Delete Selected" : "Delete All"}>
|
|
||||||
<ActionIcon
|
|
||||||
onClick={onDelete}
|
|
||||||
disabled={selectionMode && selectedPages.length === 0}
|
|
||||||
color="red"
|
|
||||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<DeleteIcon />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}>
|
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={onSplit}
|
onClick={onSplit}
|
||||||
@ -156,34 +130,7 @@ const PageEditorControls = ({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
</div>
|
||||||
|
|
||||||
{/* Export Controls */}
|
|
||||||
{selectionMode && selectedPages.length > 0 && (
|
|
||||||
<Tooltip label="Export Selected">
|
|
||||||
<ActionIcon
|
|
||||||
onClick={onExportSelected}
|
|
||||||
disabled={exportLoading}
|
|
||||||
color="blue"
|
|
||||||
variant="light"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip label="Export All">
|
|
||||||
<ActionIcon
|
|
||||||
onClick={onExportAll}
|
|
||||||
disabled={exportLoading}
|
|
||||||
color="green"
|
|
||||||
variant="light"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Paper>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -205,7 +205,7 @@ const PageThumbnail = React.memo(({
|
|||||||
}}
|
}}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
>
|
>
|
||||||
{selectionMode && (
|
{
|
||||||
<div
|
<div
|
||||||
className={styles.checkboxContainer}
|
className={styles.checkboxContainer}
|
||||||
style={{
|
style={{
|
||||||
@ -213,10 +213,9 @@ const PageThumbnail = React.memo(({
|
|||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
backgroundColor: 'white',
|
||||||
border: '1px solid #ccc',
|
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '4px',
|
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',
|
||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
@ -239,7 +238,7 @@ const PageThumbnail = React.memo(({
|
|||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
|
|
||||||
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
||||||
<div
|
<div
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box } from '@mantine/core';
|
import { Box, Center } from '@mantine/core';
|
||||||
|
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||||
import { FileMetadata } from '../../types/file';
|
import { FileMetadata } from '../../types/file';
|
||||||
import DocumentThumbnail from './filePreview/DocumentThumbnail';
|
import DocumentThumbnail from './filePreview/DocumentThumbnail';
|
||||||
import DocumentStack from './filePreview/DocumentStack';
|
import DocumentStack from './filePreview/DocumentStack';
|
||||||
@ -38,7 +39,21 @@ const FilePreview: React.FC<FilePreviewProps> = ({
|
|||||||
onPrevious,
|
onPrevious,
|
||||||
onNext
|
onNext
|
||||||
}) => {
|
}) => {
|
||||||
if (!file) return null;
|
if (!file) {
|
||||||
|
return (
|
||||||
|
<Box style={{ width: '100%', height: '100%' }}>
|
||||||
|
<Center style={{ width: '100%', height: '100%' }}>
|
||||||
|
<InsertDriveFileIcon
|
||||||
|
style={{
|
||||||
|
fontSize: '4rem',
|
||||||
|
color: 'var(--mantine-color-gray-4)',
|
||||||
|
opacity: 0.6
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const hasMultipleFiles = totalFiles > 1;
|
const hasMultipleFiles = totalFiles > 1;
|
||||||
|
|
||||||
|
@ -1,21 +1,28 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Container, Text, Button, Checkbox, Group, useMantineColorScheme } from '@mantine/core';
|
import { Container, Text, Button, Checkbox, Group, useMantineColorScheme } from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import LocalIcon from './LocalIcon';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||||
|
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||||
|
|
||||||
const LandingPage = () => {
|
const LandingPage = () => {
|
||||||
const { addMultipleFiles } = useFileHandler();
|
const { addMultipleFiles } = useFileHandler();
|
||||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { openFilesModal } = useFilesModalContext();
|
||||||
|
const [isUploadHover, setIsUploadHover] = React.useState(false);
|
||||||
|
|
||||||
const handleFileDrop = async (files: File[]) => {
|
const handleFileDrop = async (files: File[]) => {
|
||||||
await addMultipleFiles(files);
|
await addMultipleFiles(files);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddFilesClick = () => {
|
const handleOpenFilesModal = () => {
|
||||||
|
openFilesModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNativeUploadClick = () => {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -44,7 +51,7 @@ const LandingPage = () => {
|
|||||||
borderRadius: '0.5rem 0.5rem 0 0',
|
borderRadius: '0.5rem 0.5rem 0 0',
|
||||||
filter: 'var(--drop-shadow-filter)',
|
filter: 'var(--drop-shadow-filter)',
|
||||||
backgroundColor: 'var(--landing-paper-bg)',
|
backgroundColor: 'var(--landing-paper-bg)',
|
||||||
transition: 'background-color 0.2s ease',
|
transition: 'background-color 0.4s ease',
|
||||||
}}
|
}}
|
||||||
activateOnClick={false}
|
activateOnClick={false}
|
||||||
styles={{
|
styles={{
|
||||||
@ -99,26 +106,73 @@ const LandingPage = () => {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Add Files Button */}
|
{/* Add Files + Native Upload Buttons */}
|
||||||
<Button
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--landing-button-bg)',
|
display: 'flex',
|
||||||
color: 'var(--landing-button-color)',
|
alignItems: 'center',
|
||||||
border: '1px solid var(--landing-button-border)',
|
justifyContent: 'center',
|
||||||
borderRadius: '2rem',
|
gap: '0.6rem',
|
||||||
height: '38px',
|
|
||||||
width: '80%',
|
width: '80%',
|
||||||
marginTop: '0.8rem',
|
marginTop: '0.8rem',
|
||||||
marginBottom: '0.8rem',
|
marginBottom: '0.8rem'
|
||||||
|
|
||||||
}}
|
}}
|
||||||
onClick={handleAddFilesClick}
|
onMouseLeave={() => setIsUploadHover(false)}
|
||||||
>
|
>
|
||||||
<AddIcon className="text-[var(--accent-interactive)]" />
|
<Button
|
||||||
<span>
|
style={{
|
||||||
{t('fileUpload.uploadFiles', 'Upload Files')}
|
backgroundColor: 'var(--landing-button-bg)',
|
||||||
</span>
|
color: 'var(--landing-button-color)',
|
||||||
</Button>
|
border: '1px solid var(--landing-button-border)',
|
||||||
|
borderRadius: '2rem',
|
||||||
|
height: '38px',
|
||||||
|
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||||
|
paddingRight: isUploadHover ? 0 : '1rem',
|
||||||
|
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||||
|
minWidth: isUploadHover ? '58px' : undefined,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'width .5s ease, padding .5s ease'
|
||||||
|
}}
|
||||||
|
onClick={handleOpenFilesModal}
|
||||||
|
onMouseEnter={() => setIsUploadHover(false)}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||||
|
{!isUploadHover && (
|
||||||
|
<span>
|
||||||
|
{t('landing.addFiles', 'Add Files')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-label="Upload"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--landing-button-bg)',
|
||||||
|
color: 'var(--landing-button-color)',
|
||||||
|
border: '1px solid var(--landing-button-border)',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
height: '38px',
|
||||||
|
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
|
||||||
|
minWidth: '58px',
|
||||||
|
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||||
|
paddingRight: isUploadHover ? '1rem' : 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'width .5s ease, padding .5s ease'
|
||||||
|
}}
|
||||||
|
onClick={handleNativeUploadClick}
|
||||||
|
onMouseEnter={() => setIsUploadHover(true)}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||||
|
{isUploadHover && (
|
||||||
|
<span style={{ marginLeft: '.5rem' }}>
|
||||||
|
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Hidden file input for native file picker */}
|
{/* Hidden file input for native file picker */}
|
||||||
<input
|
<input
|
||||||
|
@ -1,11 +1,17 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Menu, Button, ScrollArea } from '@mantine/core';
|
import { Menu, Button, ScrollArea, ActionIcon } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { supportedLanguages } from '../../i18n';
|
import { supportedLanguages } from '../../i18n';
|
||||||
import LanguageIcon from '@mui/icons-material/Language';
|
import LocalIcon from './LocalIcon';
|
||||||
import styles from './LanguageSelector.module.css';
|
import styles from './LanguageSelector.module.css';
|
||||||
|
|
||||||
const LanguageSelector = () => {
|
interface LanguageSelectorProps {
|
||||||
|
position?: React.ComponentProps<typeof Menu>['position'];
|
||||||
|
offset?: number;
|
||||||
|
compact?: boolean; // icon-only trigger
|
||||||
|
}
|
||||||
|
|
||||||
|
const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = false }: LanguageSelectorProps) => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const [animationTriggered, setAnimationTriggered] = useState(false);
|
const [animationTriggered, setAnimationTriggered] = useState(false);
|
||||||
@ -21,26 +27,27 @@ const LanguageSelector = () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
|
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
|
||||||
// Create ripple effect at click position
|
// Create ripple effect at click position (only for button mode)
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
if (!compact) {
|
||||||
const x = event.clientX - rect.left;
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
const y = event.clientY - rect.top;
|
const x = event.clientX - rect.left;
|
||||||
|
const y = event.clientY - rect.top;
|
||||||
setRippleEffect({ x, y, key: Date.now() });
|
setRippleEffect({ x, y, key: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
// Start transition animation
|
// Start transition animation
|
||||||
setIsChanging(true);
|
setIsChanging(true);
|
||||||
setPendingLanguage(value);
|
setPendingLanguage(value);
|
||||||
|
|
||||||
// Simulate processing time for smooth transition
|
// Simulate processing time for smooth transition
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
i18n.changeLanguage(value);
|
i18n.changeLanguage(value);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsChanging(false);
|
setIsChanging(false);
|
||||||
setPendingLanguage(null);
|
setPendingLanguage(null);
|
||||||
setOpened(false);
|
setOpened(false);
|
||||||
|
|
||||||
// Clear ripple effect
|
// Clear ripple effect
|
||||||
setTimeout(() => setRippleEffect(null), 100);
|
setTimeout(() => setRippleEffect(null), 100);
|
||||||
}, 300);
|
}, 300);
|
||||||
@ -64,19 +71,9 @@ const LanguageSelector = () => {
|
|||||||
<style>
|
<style>
|
||||||
{`
|
{`
|
||||||
@keyframes ripple-expand {
|
@keyframes ripple-expand {
|
||||||
0% {
|
0% { width: 0; height: 0; opacity: 0.6; }
|
||||||
width: 0;
|
50% { opacity: 0.3; }
|
||||||
height: 0;
|
100% { width: 100px; height: 100px; opacity: 0; }
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
@ -84,8 +81,8 @@ const LanguageSelector = () => {
|
|||||||
opened={opened}
|
opened={opened}
|
||||||
onChange={setOpened}
|
onChange={setOpened}
|
||||||
width={600}
|
width={600}
|
||||||
position="bottom-start"
|
position={position}
|
||||||
offset={8}
|
offset={offset}
|
||||||
transitionProps={{
|
transitionProps={{
|
||||||
transition: 'scale-y',
|
transition: 'scale-y',
|
||||||
duration: 200,
|
duration: 200,
|
||||||
@ -93,29 +90,45 @@ const LanguageSelector = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<Button
|
{compact ? (
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
size="sm"
|
variant="subtle"
|
||||||
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
|
radius="md"
|
||||||
styles={{
|
title={currentLanguage}
|
||||||
root: {
|
className="right-rail-icon"
|
||||||
border: 'none',
|
styles={{
|
||||||
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
|
root: {
|
||||||
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
color: 'var(--right-rail-icon)',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
}}
|
||||||
label: {
|
>
|
||||||
fontSize: '12px',
|
<LocalIcon icon="language" width="1.5rem" height="1.5rem" />
|
||||||
fontWeight: 500,
|
</ActionIcon>
|
||||||
}
|
) : (
|
||||||
}}
|
<Button
|
||||||
>
|
variant="subtle"
|
||||||
<span className={styles.languageText}>
|
size="sm"
|
||||||
{currentLanguage}
|
leftSection={<LocalIcon icon="language" width="1.5rem" height="1.5rem" />}
|
||||||
</span>
|
styles={{
|
||||||
</Button>
|
root: {
|
||||||
|
border: 'none',
|
||||||
|
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
|
||||||
|
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label: { fontSize: '12px', fontWeight: 500 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.languageText}>
|
||||||
|
{currentLanguage}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown
|
<Menu.Dropdown
|
||||||
@ -181,9 +194,7 @@ const LanguageSelector = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
{!compact && rippleEffect && pendingLanguage === option.value && (
|
||||||
{/* Ripple effect */}
|
|
||||||
{rippleEffect && pendingLanguage === option.value && (
|
|
||||||
<div
|
<div
|
||||||
key={rippleEffect.key}
|
key={rippleEffect.key}
|
||||||
style={{
|
style={{
|
||||||
|
52
frontend/src/components/shared/LocalIcon.tsx
Normal file
52
frontend/src/components/shared/LocalIcon.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { addCollection, Icon } from '@iconify/react';
|
||||||
|
import iconSet from '../../assets/material-symbols-icons.json';
|
||||||
|
|
||||||
|
// Load icons synchronously at import time - guaranteed to be ready on first render
|
||||||
|
let iconsLoaded = false;
|
||||||
|
let localIconCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (iconSet) {
|
||||||
|
addCollection(iconSet);
|
||||||
|
iconsLoaded = true;
|
||||||
|
localIconCount = Object.keys(iconSet.icons || {}).length;
|
||||||
|
console.info(`✅ Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.info('ℹ️ Local icons not available - using CDN fallback');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalIconProps {
|
||||||
|
icon: string;
|
||||||
|
width?: string | number;
|
||||||
|
height?: string | number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalIcon component that uses our locally bundled Material Symbols icons
|
||||||
|
* instead of loading from CDN
|
||||||
|
*/
|
||||||
|
export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => {
|
||||||
|
// Convert our icon naming convention to the local collection format
|
||||||
|
const iconName = icon.startsWith('material-symbols:')
|
||||||
|
? icon
|
||||||
|
: `material-symbols:${icon}`;
|
||||||
|
|
||||||
|
// Development logging (only in dev mode)
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
const logKey = `icon-${iconName}`;
|
||||||
|
if (!sessionStorage.getItem(logKey)) {
|
||||||
|
const source = iconsLoaded ? 'local' : 'CDN';
|
||||||
|
console.debug(`🎯 Icon: ${iconName} (${source})`);
|
||||||
|
sessionStorage.setItem(logKey, 'logged');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always render the icon - Iconify will use local if available, CDN if not
|
||||||
|
return <Icon icon={iconName} {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocalIcon;
|
@ -1,9 +1,7 @@
|
|||||||
import React, { useState, useRef, forwardRef, useEffect } from "react";
|
import React, { useState, useRef, forwardRef, useEffect } from "react";
|
||||||
import { ActionIcon, Stack, Divider } from "@mantine/core";
|
import { ActionIcon, Stack, Divider } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MenuBookIcon from "@mui/icons-material/MenuBookRounded";
|
import LocalIcon from './LocalIcon';
|
||||||
import SettingsIcon from "@mui/icons-material/SettingsRounded";
|
|
||||||
import FolderIcon from "@mui/icons-material/FolderRounded";
|
|
||||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||||
import AppConfigModal from './AppConfigModal';
|
import AppConfigModal from './AppConfigModal';
|
||||||
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
|
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
|
||||||
@ -44,7 +42,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
|||||||
{
|
{
|
||||||
id: 'read',
|
id: 'read',
|
||||||
name: t("quickAccess.read", "Read"),
|
name: t("quickAccess.read", "Read"),
|
||||||
icon: <MenuBookIcon sx={{ fontSize: "1.5rem" }} />,
|
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
isRound: false,
|
isRound: false,
|
||||||
type: 'navigation',
|
type: 'navigation',
|
||||||
@ -54,28 +52,23 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
|||||||
handleReaderToggle();
|
handleReaderToggle();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
// TODO: Add sign
|
||||||
id: 'sign',
|
//{
|
||||||
name: t("quickAccess.sign", "Sign"),
|
// id: 'sign',
|
||||||
icon:
|
// name: t("quickAccess.sign", "Sign"),
|
||||||
<span className="material-symbols-rounded font-size-20">
|
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
|
||||||
signature
|
// size: 'lg',
|
||||||
</span>,
|
// isRound: false,
|
||||||
size: 'lg',
|
// type: 'navigation',
|
||||||
isRound: false,
|
// onClick: () => {
|
||||||
type: 'navigation',
|
// setActiveButton('sign');
|
||||||
onClick: () => {
|
// handleToolSelect('sign');
|
||||||
setActiveButton('sign');
|
// }
|
||||||
handleToolSelect('sign');
|
//},
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'automate',
|
id: 'automate',
|
||||||
name: t("quickAccess.automate", "Automate"),
|
name: t("quickAccess.automate", "Automate"),
|
||||||
icon:
|
icon: <LocalIcon icon="automation-outline" width="1.25rem" height="1.25rem" />,
|
||||||
<span className="material-symbols-rounded font-size-20">
|
|
||||||
automation
|
|
||||||
</span>,
|
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
isRound: false,
|
isRound: false,
|
||||||
type: 'navigation',
|
type: 'navigation',
|
||||||
@ -87,28 +80,26 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
|||||||
{
|
{
|
||||||
id: 'files',
|
id: 'files',
|
||||||
name: t("quickAccess.files", "Files"),
|
name: t("quickAccess.files", "Files"),
|
||||||
icon: <FolderIcon sx={{ fontSize: "1.25rem" }} />,
|
icon: <LocalIcon icon="folder-rounded" width="1.25rem" height="1.25rem" />,
|
||||||
isRound: true,
|
isRound: true,
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
type: 'modal',
|
type: 'modal',
|
||||||
onClick: handleFilesButtonClick
|
onClick: handleFilesButtonClick
|
||||||
},
|
},
|
||||||
{
|
//TODO: Activity
|
||||||
id: 'activity',
|
//{
|
||||||
name: t("quickAccess.activity", "Activity"),
|
// id: 'activity',
|
||||||
icon:
|
// name: t("quickAccess.activity", "Activity"),
|
||||||
<span className="material-symbols-rounded font-size-20">
|
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
|
||||||
vital_signs
|
// isRound: true,
|
||||||
</span>,
|
// size: 'lg',
|
||||||
isRound: true,
|
// type: 'navigation',
|
||||||
size: 'lg',
|
// onClick: () => setActiveButton('activity')
|
||||||
type: 'navigation',
|
//},
|
||||||
onClick: () => setActiveButton('activity')
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'config',
|
id: 'config',
|
||||||
name: t("quickAccess.config", "Config"),
|
name: t("quickAccess.config", "Config"),
|
||||||
icon: <SettingsIcon sx={{ fontSize: "1rem" }} />,
|
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
type: 'modal',
|
type: 'modal',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@ -179,8 +170,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Add divider after Automate button (index 2) */}
|
{/* Add divider after Automate button (index 1) and Files button (index 2) */}
|
||||||
{index === 2 && (
|
{index === 1 && (
|
||||||
<Divider
|
<Divider
|
||||||
size="xs"
|
size="xs"
|
||||||
className="content-divider"
|
className="content-divider"
|
||||||
|
377
frontend/src/components/shared/RightRail.tsx
Normal file
377
frontend/src/components/shared/RightRail.tsx
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
import React, { useCallback, useState, useEffect, useMemo } from 'react';
|
||||||
|
import { ActionIcon, Divider, Popover } from '@mantine/core';
|
||||||
|
import LocalIcon from './LocalIcon';
|
||||||
|
import './rightRail/RightRail.css';
|
||||||
|
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||||
|
import { useRightRail } from '../../contexts/RightRailContext';
|
||||||
|
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
|
||||||
|
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import LanguageSelector from '../shared/LanguageSelector';
|
||||||
|
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||||
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
|
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
|
||||||
|
|
||||||
|
export default function RightRail() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { toggleTheme } = useRainbowThemeContext();
|
||||||
|
const { buttons, actions } = useRightRail();
|
||||||
|
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
|
||||||
|
|
||||||
|
// Access PageEditor functions for page-editor-specific actions
|
||||||
|
const { pageEditorFunctions } = useToolWorkflow();
|
||||||
|
|
||||||
|
// CSV input state for page selection
|
||||||
|
const [csvInput, setCsvInput] = useState<string>("");
|
||||||
|
|
||||||
|
// Navigation view
|
||||||
|
const { currentMode: currentView } = useNavigationState();
|
||||||
|
|
||||||
|
// File state and selection
|
||||||
|
const { state, selectors } = useFileState();
|
||||||
|
const { selectedFiles, selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection();
|
||||||
|
const { removeFiles } = useFileManagement();
|
||||||
|
|
||||||
|
const activeFiles = selectors.getFiles();
|
||||||
|
const filesSignature = selectors.getFilesSignature();
|
||||||
|
const fileRecords = selectors.getFileRecords();
|
||||||
|
|
||||||
|
// Compute selection state and total items
|
||||||
|
const getSelectionState = useCallback(() => {
|
||||||
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||||
|
const totalItems = activeFiles.length;
|
||||||
|
const selectedCount = selectedFileIds.length;
|
||||||
|
return { totalItems, selectedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentView === 'pageEditor') {
|
||||||
|
let totalItems = 0;
|
||||||
|
fileRecords.forEach(rec => {
|
||||||
|
const pf = rec.processedFile;
|
||||||
|
if (pf) {
|
||||||
|
totalItems += (pf.totalPages as number) || (pf.pages?.length || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const selectedCount = Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length : 0;
|
||||||
|
return { totalItems, selectedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalItems: 0, selectedCount: 0 };
|
||||||
|
}, [currentView, activeFiles, fileRecords, selectedFileIds, selectedPageNumbers]);
|
||||||
|
|
||||||
|
const { totalItems, selectedCount } = getSelectionState();
|
||||||
|
|
||||||
|
const handleSelectAll = useCallback(() => {
|
||||||
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||||
|
// Select all file IDs
|
||||||
|
const allIds = state.files.ids;
|
||||||
|
setSelectedFiles(allIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentView === 'pageEditor') {
|
||||||
|
let totalPages = 0;
|
||||||
|
fileRecords.forEach(rec => {
|
||||||
|
const pf = rec.processedFile;
|
||||||
|
if (pf) {
|
||||||
|
totalPages += (pf.totalPages as number) || (pf.pages?.length || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalPages > 0) {
|
||||||
|
setSelectedPages(Array.from({ length: totalPages }, (_, i) => i + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentView, state.files.ids, fileRecords, setSelectedFiles, setSelectedPages]);
|
||||||
|
|
||||||
|
const handleDeselectAll = useCallback(() => {
|
||||||
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||||
|
setSelectedFiles([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentView === 'pageEditor') {
|
||||||
|
setSelectedPages([]);
|
||||||
|
}
|
||||||
|
}, [currentView, setSelectedFiles, setSelectedPages]);
|
||||||
|
|
||||||
|
const handleExportAll = useCallback(() => {
|
||||||
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||||
|
// Download selected files (or all if none selected)
|
||||||
|
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||||
|
|
||||||
|
filesToDownload.forEach(file => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(file);
|
||||||
|
link.download = file.name;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
});
|
||||||
|
} else if (currentView === 'pageEditor') {
|
||||||
|
// Export all pages (not just selected)
|
||||||
|
pageEditorFunctions?.onExportAll?.();
|
||||||
|
}
|
||||||
|
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
|
||||||
|
|
||||||
|
const handleCloseSelected = useCallback(() => {
|
||||||
|
if (currentView !== 'fileEditor') return;
|
||||||
|
if (selectedFileIds.length === 0) return;
|
||||||
|
|
||||||
|
// Close only selected files (do not delete from storage)
|
||||||
|
removeFiles(selectedFileIds, false);
|
||||||
|
|
||||||
|
// Clear selection after closing
|
||||||
|
setSelectedFiles([]);
|
||||||
|
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
|
||||||
|
|
||||||
|
// CSV parsing functions for page selection
|
||||||
|
const parseCSVInput = useCallback((csv: string) => {
|
||||||
|
const pageNumbers: number[] = [];
|
||||||
|
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
ranges.forEach(range => {
|
||||||
|
if (range.includes('-')) {
|
||||||
|
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
pageNumbers.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const pageNum = parseInt(range);
|
||||||
|
if (pageNum > 0) {
|
||||||
|
pageNumbers.push(pageNum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return pageNumbers;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updatePagesFromCSV = useCallback(() => {
|
||||||
|
const rawPages = parseCSVInput(csvInput);
|
||||||
|
// Determine max page count from processed records
|
||||||
|
const maxPages = fileRecords.reduce((sum, rec) => {
|
||||||
|
const pf = rec.processedFile;
|
||||||
|
if (!pf) return sum;
|
||||||
|
return sum + ((pf.totalPages as number) || (pf.pages?.length || 0));
|
||||||
|
}, 0);
|
||||||
|
const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b);
|
||||||
|
setSelectedPages(normalized);
|
||||||
|
}, [csvInput, parseCSVInput, fileRecords, setSelectedPages]);
|
||||||
|
|
||||||
|
// Sync csvInput with selectedPageNumbers changes
|
||||||
|
useEffect(() => {
|
||||||
|
const sortedPageNumbers = Array.isArray(selectedPageNumbers)
|
||||||
|
? [...selectedPageNumbers].sort((a, b) => a - b)
|
||||||
|
: [];
|
||||||
|
const newCsvInput = sortedPageNumbers.join(', ');
|
||||||
|
setCsvInput(newCsvInput);
|
||||||
|
}, [selectedPageNumbers]);
|
||||||
|
|
||||||
|
// Clear CSV input when files change (use stable signature to avoid ref churn)
|
||||||
|
useEffect(() => {
|
||||||
|
setCsvInput("");
|
||||||
|
}, [filesSignature]);
|
||||||
|
|
||||||
|
// Mount/visibility for page-editor-only buttons to allow exit animation, then remove to avoid flex gap
|
||||||
|
const [pageControlsMounted, setPageControlsMounted] = useState<boolean>(currentView === 'pageEditor');
|
||||||
|
const [pageControlsVisible, setPageControlsVisible] = useState<boolean>(currentView === 'pageEditor');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentView === 'pageEditor') {
|
||||||
|
// Mount and show
|
||||||
|
setPageControlsMounted(true);
|
||||||
|
// Next tick to ensure transition applies
|
||||||
|
requestAnimationFrame(() => setPageControlsVisible(true));
|
||||||
|
} else {
|
||||||
|
// Start exit animation
|
||||||
|
setPageControlsVisible(false);
|
||||||
|
// After transition, unmount to remove flex gap
|
||||||
|
const timer = setTimeout(() => setPageControlsMounted(false), 240);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [currentView]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="right-rail">
|
||||||
|
<div className="right-rail-inner">
|
||||||
|
{topButtons.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="right-rail-section">
|
||||||
|
{topButtons.map(btn => (
|
||||||
|
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
radius="md"
|
||||||
|
className="right-rail-icon"
|
||||||
|
onClick={() => actions[btn.id]?.()}
|
||||||
|
disabled={btn.disabled}
|
||||||
|
>
|
||||||
|
{btn.icon}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Divider className="right-rail-divider" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
||||||
|
<div
|
||||||
|
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||||
|
aria-hidden={currentView === 'viewer'}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
{/* Select All Button */}
|
||||||
|
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow>
|
||||||
|
<div>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
radius="md"
|
||||||
|
className="right-rail-icon"
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Deselect All Button */}
|
||||||
|
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
|
||||||
|
<div>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
radius="md"
|
||||||
|
className="right-rail-icon"
|
||||||
|
onClick={handleDeselectAll}
|
||||||
|
disabled={currentView === 'viewer' || selectedCount === 0}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Select by Numbers - page editor only, with animated presence */}
|
||||||
|
{pageControlsMounted && (
|
||||||
|
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow>
|
||||||
|
|
||||||
|
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||||
|
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||||
|
<Popover.Target>
|
||||||
|
<div style={{ display: 'inline-flex' }}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
radius="md"
|
||||||
|
className="right-rail-icon"
|
||||||
|
disabled={!pageControlsVisible || totalItems === 0}
|
||||||
|
aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
</div>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<div style={{ minWidth: 280 }}>
|
||||||
|
<BulkSelectionPanel
|
||||||
|
csvInput={csvInput}
|
||||||
|
setCsvInput={setCsvInput}
|
||||||
|
selectedPages={Array.isArray(selectedPageNumbers) ? selectedPageNumbers : []}
|
||||||
|
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Selected Pages - page editor only, with animated presence */}
|
||||||
|
{pageControlsMounted && (
|
||||||
|
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow>
|
||||||
|
|
||||||
|
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||||
|
<div style={{ display: 'inline-flex' }}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
radius="md"
|
||||||
|
className="right-rail-icon"
|
||||||
|
onClick={() => { pageEditorFunctions?.handleDelete?.(); setSelectedPages([]); }}
|
||||||
|
disabled={!pageControlsVisible || (Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length === 0 : true)}
|
||||||
|
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
|
||||||
|
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow>
|
||||||
|
<div>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
radius="md"
|
||||||
|
className="right-rail-icon"
|
||||||
|
onClick={currentView === 'pageEditor' ? () => pageEditorFunctions?.closePdf?.() : handleCloseSelected}
|
||||||
|
disabled={
|
||||||
|
currentView === 'viewer' ||
|
||||||
|
(currentView === 'fileEditor' && selectedCount === 0) ||
|
||||||
|
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider className="right-rail-divider" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme toggle and Language dropdown */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
radius="md"
|
||||||
|
className="right-rail-icon"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<LanguageSelector position="left-start" offset={6} compact />
|
||||||
|
|
||||||
|
<Tooltip content={
|
||||||
|
currentView === 'pageEditor'
|
||||||
|
? t('rightRail.exportAll', 'Export PDF')
|
||||||
|
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
|
||||||
|
} position="left" offset={12} arrow>
|
||||||
|
<div>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
radius="md"
|
||||||
|
className="right-rail-icon"
|
||||||
|
onClick={handleExportAll}
|
||||||
|
disabled={currentView === 'viewer' || totalItems === 0}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="right-rail-spacer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
|||||||
import React, { forwardRef } from 'react';
|
import React, { forwardRef } from 'react';
|
||||||
import { useMantineColorScheme } from '@mantine/core';
|
import { useMantineColorScheme } from '@mantine/core';
|
||||||
|
import LocalIcon from './LocalIcon';
|
||||||
import styles from './textInput/TextInput.module.css';
|
import styles from './textInput/TextInput.module.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,6 +31,8 @@ export interface TextInputProps {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
/** Accessibility label */
|
/** Accessibility label */
|
||||||
'aria-label'?: string;
|
'aria-label'?: string;
|
||||||
|
/** Focus event handler */
|
||||||
|
onFocus?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
||||||
@ -45,6 +48,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
'aria-label': ariaLabel,
|
'aria-label': ariaLabel,
|
||||||
|
onFocus,
|
||||||
...props
|
...props
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
@ -62,7 +66,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
|||||||
return (
|
return (
|
||||||
<div className={`${styles.container} ${className}`} style={style}>
|
<div className={`${styles.container} ${className}`} style={style}>
|
||||||
{icon && (
|
{icon && (
|
||||||
<span
|
<span
|
||||||
className={styles.icon}
|
className={styles.icon}
|
||||||
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
|
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
|
||||||
>
|
>
|
||||||
@ -80,6 +84,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
onFocus={onFocus}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF',
|
backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF',
|
||||||
color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382',
|
color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382',
|
||||||
@ -96,7 +101,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
|||||||
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
|
style={{ color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382' }}
|
||||||
aria-label="Clear input"
|
aria-label="Clear input"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-rounded">close</span>
|
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import LocalIcon from './LocalIcon';
|
||||||
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils';
|
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils';
|
||||||
import { useTooltipPosition } from '../../hooks/useTooltipPosition';
|
import { useTooltipPosition } from '../../hooks/useTooltipPosition';
|
||||||
import { TooltipTip } from '../../types/tips';
|
import { TooltipTip } from '../../types/tips';
|
||||||
@ -124,8 +125,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
if (sidebarTooltip) return null;
|
if (sidebarTooltip) return null;
|
||||||
|
|
||||||
switch (position) {
|
switch (position) {
|
||||||
case 'top': return "tooltip-arrow tooltip-arrow-top";
|
case 'top': return "tooltip-arrow tooltip-arrow-bottom";
|
||||||
case 'bottom': return "tooltip-arrow tooltip-arrow-bottom";
|
case 'bottom': return "tooltip-arrow tooltip-arrow-top";
|
||||||
case 'left': return "tooltip-arrow tooltip-arrow-left";
|
case 'left': return "tooltip-arrow tooltip-arrow-left";
|
||||||
case 'right': return "tooltip-arrow tooltip-arrow-right";
|
case 'right': return "tooltip-arrow tooltip-arrow-right";
|
||||||
default: return "tooltip-arrow tooltip-arrow-right";
|
default: return "tooltip-arrow tooltip-arrow-right";
|
||||||
@ -150,7 +151,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: coords.top,
|
top: coords.top,
|
||||||
left: coords.left,
|
left: coords.left,
|
||||||
width: (maxWidth !== undefined ? maxWidth : '25rem'),
|
width: (maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' : undefined)),
|
||||||
minWidth: minWidth,
|
minWidth: minWidth,
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
visibility: positionReady ? 'visible' : 'hidden',
|
visibility: positionReady ? 'visible' : 'hidden',
|
||||||
@ -171,9 +172,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
}}
|
}}
|
||||||
title="Close tooltip"
|
title="Close tooltip"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-rounded">
|
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||||
close
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{arrow && getArrowClass() && (
|
{arrow && getArrowClass() && (
|
||||||
|
@ -1,23 +1,64 @@
|
|||||||
import React, { useState, useCallback, useMemo } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import { Button, SegmentedControl, Loader } from "@mantine/core";
|
import { SegmentedControl, Loader } from "@mantine/core";
|
||||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||||
import LanguageSelector from "./LanguageSelector";
|
|
||||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
|
||||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
|
||||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
|
||||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||||
import FolderIcon from "@mui/icons-material/Folder";
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
import { Group } from "@mantine/core";
|
import { ModeType, isValidMode } from '../../contexts/NavigationContext';
|
||||||
import { ModeType } from '../../contexts/NavigationContext';
|
|
||||||
|
|
||||||
// Stable view option objects that don't recreate on every render
|
const viewOptionStyle = {
|
||||||
const VIEW_OPTIONS_BASE = [
|
display: 'inline-flex',
|
||||||
{ value: "viewer", icon: VisibilityIcon },
|
flexDirection: 'row',
|
||||||
{ value: "pageEditor", icon: EditNoteIcon },
|
alignItems: 'center',
|
||||||
{ value: "fileEditor", icon: FolderIcon },
|
gap: 6,
|
||||||
] as const;
|
whiteSpace: 'nowrap',
|
||||||
|
paddingTop: '0.3rem',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Create view options with icons and loading states
|
||||||
|
const createViewOptions = (switchingTo: ModeType | null) => [
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<div style={viewOptionStyle as React.CSSProperties}>
|
||||||
|
{switchingTo === "viewer" ? (
|
||||||
|
<Loader size="xs" />
|
||||||
|
) : (
|
||||||
|
<VisibilityIcon fontSize="small" />
|
||||||
|
)}
|
||||||
|
<span>Viewer</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: "viewer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<div style={viewOptionStyle as React.CSSProperties}>
|
||||||
|
{switchingTo === "pageEditor" ? (
|
||||||
|
<Loader size="xs" />
|
||||||
|
) : (
|
||||||
|
<EditNoteIcon fontSize="small" />
|
||||||
|
)}
|
||||||
|
<span>Page Editor</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: "pageEditor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<div style={viewOptionStyle as React.CSSProperties}>
|
||||||
|
{switchingTo === "fileEditor" ? (
|
||||||
|
<Loader size="xs" />
|
||||||
|
) : (
|
||||||
|
<FolderIcon fontSize="small" />
|
||||||
|
)}
|
||||||
|
<span>File Manager</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: "fileEditor",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
interface TopControlsProps {
|
interface TopControlsProps {
|
||||||
currentView: ModeType;
|
currentView: ModeType;
|
||||||
@ -30,90 +71,60 @@ const TopControls = ({
|
|||||||
setCurrentView,
|
setCurrentView,
|
||||||
selectedToolKey,
|
selectedToolKey,
|
||||||
}: TopControlsProps) => {
|
}: TopControlsProps) => {
|
||||||
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
const [switchingTo, setSwitchingTo] = useState<string | null>(null);
|
const [switchingTo, setSwitchingTo] = useState<ModeType | null>(null);
|
||||||
|
|
||||||
const isToolSelected = selectedToolKey !== null;
|
const isToolSelected = selectedToolKey !== null;
|
||||||
|
|
||||||
const handleViewChange = useCallback((view: string) => {
|
const handleViewChange = useCallback((view: string) => {
|
||||||
// Guard against redundant changes
|
if (!isValidMode(view)) {
|
||||||
if (view === currentView) return;
|
// Ignore invalid values defensively
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mode = view as ModeType;
|
||||||
|
|
||||||
// Show immediate feedback
|
// Show immediate feedback
|
||||||
setSwitchingTo(view);
|
setSwitchingTo(mode as ModeType);
|
||||||
|
|
||||||
// Defer the heavy view change to next frame so spinner can render
|
// Defer the heavy view change to next frame so spinner can render
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
// Give the spinner one more frame to show
|
// Give the spinner one more frame to show
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setCurrentView(view as ModeType);
|
setCurrentView(mode as ModeType);
|
||||||
|
|
||||||
// Clear the loading state after view change completes
|
// Clear the loading state after view change completes
|
||||||
setTimeout(() => setSwitchingTo(null), 300);
|
setTimeout(() => setSwitchingTo(null), 300);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [setCurrentView, currentView]);
|
}, [setCurrentView]);
|
||||||
|
|
||||||
// Memoize the SegmentedControl data with stable references
|
|
||||||
const viewOptions = useMemo(() =>
|
|
||||||
VIEW_OPTIONS_BASE.map(option => ({
|
|
||||||
value: option.value,
|
|
||||||
label: (
|
|
||||||
<Group gap={option.value === "viewer" ? 5 : 4}>
|
|
||||||
{switchingTo === option.value ? (
|
|
||||||
<Loader size="xs" />
|
|
||||||
) : (
|
|
||||||
<option.icon fontSize="small" />
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
)
|
|
||||||
})), [switchingTo]);
|
|
||||||
|
|
||||||
const getThemeIcon = () => {
|
|
||||||
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
|
|
||||||
if (themeMode === "dark") return <LightModeIcon />;
|
|
||||||
return <DarkModeIcon />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||||
<div className={`absolute left-4 pointer-events-auto flex gap-2 items-center ${
|
|
||||||
isToolSelected ? 'top-4' : 'top-1/2 -translate-y-1/2'
|
|
||||||
}`}>
|
|
||||||
<Button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
variant="subtle"
|
|
||||||
size="md"
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
disabled={isToggleDisabled}
|
|
||||||
className={isRainbowMode ? rainbowStyles.rainbowButton : ''}
|
|
||||||
title={
|
|
||||||
isToggleDisabled
|
|
||||||
? "Button disabled for 3 seconds..."
|
|
||||||
: isRainbowMode
|
|
||||||
? "Rainbow Mode Active! Click to exit"
|
|
||||||
: "Toggle theme (click rapidly 6 times for a surprise!)"
|
|
||||||
}
|
|
||||||
style={isToggleDisabled ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
|
|
||||||
>
|
|
||||||
{getThemeIcon()}
|
|
||||||
</Button>
|
|
||||||
<LanguageSelector />
|
|
||||||
</div>
|
|
||||||
{!isToolSelected && (
|
{!isToolSelected && (
|
||||||
<div className="flex justify-center items-center h-full pointer-events-auto">
|
<div className="flex justify-center mt-[0.5rem]">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
data={viewOptions}
|
data={createViewOptions(switchingTo)}
|
||||||
value={currentView}
|
value={currentView}
|
||||||
onChange={handleViewChange}
|
onChange={handleViewChange}
|
||||||
color="blue"
|
color="blue"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
size="md"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
|
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
|
||||||
style={{
|
style={{
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
opacity: switchingTo ? 0.8 : 1,
|
opacity: switchingTo ? 0.8 : 1,
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
borderRadius: 9999,
|
||||||
|
},
|
||||||
|
control: {
|
||||||
|
borderRadius: 9999,
|
||||||
|
},
|
||||||
|
indicator: {
|
||||||
|
borderRadius: 9999,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
108
frontend/src/components/shared/rightRail/RightRail.README.md
Normal file
108
frontend/src/components/shared/rightRail/RightRail.README.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# RightRail Component
|
||||||
|
|
||||||
|
A dynamic vertical toolbar on the right side of the application that supports both static buttons (Undo/Redo, Save, Print, Share) and dynamic buttons registered by tools.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- **Top Section**: Dynamic buttons from tools (empty when none)
|
||||||
|
- **Middle Section**: Grid, Cut, Undo, Redo
|
||||||
|
- **Bottom Section**: Save, Print, Share
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### For Tools (Recommended)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useRightRailButtons } from '../hooks/useRightRailButtons';
|
||||||
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
|
|
||||||
|
function MyTool() {
|
||||||
|
const handleAction = useCallback(() => {
|
||||||
|
// Your action here
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useRightRailButtons([
|
||||||
|
{
|
||||||
|
id: 'my-action',
|
||||||
|
icon: <PlayArrowIcon />,
|
||||||
|
tooltip: 'Execute Action',
|
||||||
|
onClick: handleAction,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return <div>My Tool</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Buttons
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useRightRailButtons([
|
||||||
|
{
|
||||||
|
id: 'primary',
|
||||||
|
icon: <StarIcon />,
|
||||||
|
tooltip: 'Primary Action',
|
||||||
|
order: 1,
|
||||||
|
onClick: handlePrimary,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'secondary',
|
||||||
|
icon: <SettingsIcon />,
|
||||||
|
tooltip: 'Secondary Action',
|
||||||
|
order: 2,
|
||||||
|
onClick: handleSecondary,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Buttons
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useRightRailButtons([
|
||||||
|
// Always show
|
||||||
|
{
|
||||||
|
id: 'process',
|
||||||
|
icon: <PlayArrowIcon />,
|
||||||
|
tooltip: 'Process',
|
||||||
|
disabled: isProcessing,
|
||||||
|
onClick: handleProcess,
|
||||||
|
},
|
||||||
|
// Only show when condition met
|
||||||
|
...(hasResults ? [{
|
||||||
|
id: 'export',
|
||||||
|
icon: <DownloadIcon />,
|
||||||
|
tooltip: 'Export',
|
||||||
|
onClick: handleExport,
|
||||||
|
}] : []),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Button Config
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RightRailButtonWithAction {
|
||||||
|
id: string; // Unique identifier
|
||||||
|
icon: React.ReactNode; // Icon component
|
||||||
|
tooltip: string; // Hover tooltip
|
||||||
|
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
|
||||||
|
order?: number; // Sort order (default: 0)
|
||||||
|
disabled?: boolean; // Disabled state (default: false)
|
||||||
|
visible?: boolean; // Visibility (default: true)
|
||||||
|
onClick: () => void; // Click handler
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Built-in Features
|
||||||
|
|
||||||
|
- **Undo/Redo**: Automatically integrates with Page Editor
|
||||||
|
- **Theme Support**: Light/dark mode with CSS variables
|
||||||
|
- **Auto Cleanup**: Buttons unregister when tool unmounts
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Use descriptive IDs: `'compress-optimize'`, `'ocr-process'`
|
||||||
|
- Choose appropriate Material-UI icons
|
||||||
|
- Keep tooltips concise: `'Compress PDF'`, `'Process with OCR'`
|
||||||
|
- Use `useCallback` for click handlers to prevent re-registration
|
127
frontend/src/components/shared/rightRail/RightRail.css
Normal file
127
frontend/src/components/shared/rightRail/RightRail.css
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
.right-rail {
|
||||||
|
background-color: var(--right-rail-bg);
|
||||||
|
width: 3.5rem;
|
||||||
|
min-width: 3.5rem;
|
||||||
|
max-width: 3.5rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
border-left: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-section {
|
||||||
|
background-color: var(--right-rail-foreground);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-divider {
|
||||||
|
width: 2.75rem;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--tool-subcategory-rule-color);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-icon {
|
||||||
|
color: var(--right-rail-icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-icon[aria-disabled="true"],
|
||||||
|
.right-rail-icon[disabled] {
|
||||||
|
color: var(--right-rail-icon-disabled) !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated grow-down slot for buttons (mirrors current-tool-slot behavior) */
|
||||||
|
.right-rail-slot {
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: max-height 450ms ease-out, opacity 300ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-enter {
|
||||||
|
animation: rightRailGrowDown 450ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-exit {
|
||||||
|
animation: rightRailShrinkUp 450ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-slot.visible {
|
||||||
|
max-height: 18rem; /* increased to fit additional controls + divider */
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rightRailGrowDown {
|
||||||
|
0% {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
max-height: 18rem;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rightRailShrinkUp {
|
||||||
|
0% {
|
||||||
|
max-height: 18rem;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove bottom margin from close icon */
|
||||||
|
.right-rail-slot .right-rail-icon {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline appear/disappear animation for page-number selector button */
|
||||||
|
.right-rail-fade {
|
||||||
|
transition-property: opacity, transform, max-height, visibility;
|
||||||
|
transition-duration: 220ms, 220ms, 220ms, 0s;
|
||||||
|
transition-timing-function: ease, ease, ease, linear;
|
||||||
|
transition-delay: 0s, 0s, 0s, 0s;
|
||||||
|
transform-origin: top center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-fade.enter {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
max-height: 3rem;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-rail-fade.exit {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85);
|
||||||
|
max-height: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
/* delay visibility change so opacity/max-height can finish */
|
||||||
|
transition-delay: 0s, 0s, 0s, 220ms;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
@ -160,7 +160,7 @@
|
|||||||
.tooltip-arrow-top {
|
.tooltip-arrow-top {
|
||||||
top: -0.25rem;
|
top: -0.25rem;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%) rotate(45deg);
|
transform: translateX(-50%) rotate(-135deg);
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ import ToolRenderer from './ToolRenderer';
|
|||||||
import ToolSearch from './toolPicker/ToolSearch';
|
import ToolSearch from './toolPicker/ToolSearch';
|
||||||
import { useSidebarContext } from "../../contexts/SidebarContext";
|
import { useSidebarContext } from "../../contexts/SidebarContext";
|
||||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||||
|
import { Stack, ScrollArea } from '@mantine/core';
|
||||||
|
|
||||||
// No props needed - component uses context
|
// No props needed - component uses context
|
||||||
|
|
||||||
@ -91,15 +92,17 @@ export default function ToolPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Selected Tool Content View
|
// Selected Tool Content View
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Tool content */}
|
{/* Tool content */}
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
{selectedToolKey && (
|
<ScrollArea h="100%">
|
||||||
<ToolRenderer
|
{selectedToolKey && (
|
||||||
selectedToolKey={selectedToolKey}
|
<ToolRenderer
|
||||||
onPreviewFile={setPreviewFile}
|
selectedToolKey={selectedToolKey}
|
||||||
/>
|
onPreviewFile={setPreviewFile}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -25,19 +25,39 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
|||||||
const quickAccessRef = useRef<HTMLDivElement>(null);
|
const quickAccessRef = useRef<HTMLDivElement>(null);
|
||||||
const allToolsRef = useRef<HTMLDivElement>(null);
|
const allToolsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// On resize adjust headers height to offset height
|
// Keep header heights in sync with any dynamic size changes
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const update = () => {
|
const update = () => {
|
||||||
if (quickHeaderRef.current) {
|
if (quickHeaderRef.current) {
|
||||||
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight);
|
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight || 0);
|
||||||
}
|
}
|
||||||
if (allHeaderRef.current) {
|
if (allHeaderRef.current) {
|
||||||
setAllHeaderHeight(allHeaderRef.current.offsetHeight);
|
setAllHeaderHeight(allHeaderRef.current.offsetHeight || 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
update();
|
update();
|
||||||
|
|
||||||
|
// Update on window resize
|
||||||
window.addEventListener("resize", update);
|
window.addEventListener("resize", update);
|
||||||
return () => window.removeEventListener("resize", update);
|
|
||||||
|
// Update on element resize (e.g., font load, badge count change, zoom)
|
||||||
|
const observers: ResizeObserver[] = [];
|
||||||
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
|
const observe = (el: HTMLDivElement | null, cb: () => void) => {
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(() => cb());
|
||||||
|
ro.observe(el);
|
||||||
|
observers.push(ro);
|
||||||
|
};
|
||||||
|
observe(quickHeaderRef.current, update);
|
||||||
|
observe(allHeaderRef.current, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", update);
|
||||||
|
observers.forEach(o => o.disconnect());
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { sections: visibleSections } = useToolSections(filteredTools);
|
const { sections: visibleSections } = useToolSections(filteredTools);
|
||||||
@ -85,7 +105,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
|||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
overflowX: "hidden",
|
overflowX: "hidden",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
height: "100%"
|
height: "100%",
|
||||||
|
marginTop: -2
|
||||||
}}
|
}}
|
||||||
className="tool-picker-scrollable"
|
className="tool-picker-scrollable"
|
||||||
>
|
>
|
||||||
@ -109,7 +130,6 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
|||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||||
marginBottom: -1,
|
|
||||||
padding: "0.5rem 1rem",
|
padding: "0.5rem 1rem",
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
background: "var(--tool-header-bg)",
|
background: "var(--tool-header-bg)",
|
||||||
@ -117,7 +137,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
|||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between"
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
onClick={() => scrollTo(quickAccessRef)}
|
onClick={() => scrollTo(quickAccessRef)}
|
||||||
>
|
>
|
||||||
@ -152,7 +172,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
|||||||
ref={allHeaderRef}
|
ref={allHeaderRef}
|
||||||
style={{
|
style={{
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
top: quickSection ? quickHeaderHeight - 1: 0,
|
top: quickSection ? quickHeaderHeight -1 : 0,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||||
|
@ -98,7 +98,7 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
|
|||||||
const saveAutomation = async () => {
|
const saveAutomation = async () => {
|
||||||
if (!canSaveAutomation()) return;
|
if (!canSaveAutomation()) return;
|
||||||
|
|
||||||
const automation = {
|
const automationData = {
|
||||||
name: automationName.trim(),
|
name: automationName.trim(),
|
||||||
description: '',
|
description: '',
|
||||||
operations: selectedTools.map(tool => ({
|
operations: selectedTools.map(tool => ({
|
||||||
@ -109,7 +109,30 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { automationStorage } = await import('../../../services/automationStorage');
|
const { automationStorage } = await import('../../../services/automationStorage');
|
||||||
const savedAutomation = await automationStorage.saveAutomation(automation);
|
let savedAutomation;
|
||||||
|
|
||||||
|
if (mode === AutomationMode.EDIT && existingAutomation) {
|
||||||
|
// For edit mode, check if name has changed
|
||||||
|
const nameChanged = automationName.trim() !== existingAutomation.name;
|
||||||
|
|
||||||
|
if (nameChanged) {
|
||||||
|
// Name changed - create new automation
|
||||||
|
savedAutomation = await automationStorage.saveAutomation(automationData);
|
||||||
|
} else {
|
||||||
|
// Name unchanged - update existing automation
|
||||||
|
const updatedAutomation = {
|
||||||
|
...existingAutomation,
|
||||||
|
...automationData,
|
||||||
|
id: existingAutomation.id,
|
||||||
|
createdAt: existingAutomation.createdAt
|
||||||
|
};
|
||||||
|
savedAutomation = await automationStorage.updateAutomation(updatedAutomation);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create mode - always create new automation
|
||||||
|
savedAutomation = await automationStorage.saveAutomation(automationData);
|
||||||
|
}
|
||||||
|
|
||||||
onComplete(savedAutomation);
|
onComplete(savedAutomation);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving automation:', error);
|
console.error('Error saving automation:', error);
|
||||||
|
@ -4,10 +4,14 @@ import { Button, Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
|
|||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
|
|
||||||
interface AutomationEntryProps {
|
interface AutomationEntryProps {
|
||||||
/** Optional title for the automation (usually for custom ones) */
|
/** Optional title for the automation (usually for custom ones) */
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** Optional description for tooltip */
|
||||||
|
description?: string;
|
||||||
/** MUI Icon component for the badge */
|
/** MUI Icon component for the badge */
|
||||||
badgeIcon?: React.ComponentType<any>;
|
badgeIcon?: React.ComponentType<any>;
|
||||||
/** Array of tool operation names in the workflow */
|
/** Array of tool operation names in the workflow */
|
||||||
@ -22,17 +26,21 @@ interface AutomationEntryProps {
|
|||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
/** Delete handler */
|
/** Delete handler */
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
|
/** Copy handler (for suggested automations) */
|
||||||
|
onCopy?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AutomationEntry({
|
export default function AutomationEntry({
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
badgeIcon: BadgeIcon,
|
badgeIcon: BadgeIcon,
|
||||||
operations,
|
operations,
|
||||||
onClick,
|
onClick,
|
||||||
keepIconColor = false,
|
keepIconColor = false,
|
||||||
showMenu = false,
|
showMenu = false,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete
|
onDelete,
|
||||||
|
onCopy
|
||||||
}: AutomationEntryProps) {
|
}: AutomationEntryProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
@ -41,6 +49,47 @@ export default function AutomationEntry({
|
|||||||
// 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;
|
||||||
|
|
||||||
|
// Create tooltip content with description and tool chain
|
||||||
|
const createTooltipContent = () => {
|
||||||
|
if (!description) return null;
|
||||||
|
|
||||||
|
const toolChain = operations.map((op, index) => (
|
||||||
|
<React.Fragment key={`${op}-${index}`}>
|
||||||
|
<Text
|
||||||
|
component="span"
|
||||||
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
style={{
|
||||||
|
color: 'var(--mantine-primary-color-filled)',
|
||||||
|
background: 'var(--mantine-primary-color-light)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(`${op}.title`, op)}
|
||||||
|
</Text>
|
||||||
|
{index < operations.length - 1 && (
|
||||||
|
<Text component="span" size="sm" mx={4}>
|
||||||
|
→
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minWidth: '400px', width: 'auto' }}>
|
||||||
|
<Text size="sm" mb={8} style={{ whiteSpace: 'normal', wordWrap: 'break-word' }}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', whiteSpace: 'nowrap' }}>
|
||||||
|
{toolChain}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (title) {
|
if (title) {
|
||||||
// Custom automation with title
|
// Custom automation with title
|
||||||
@ -89,7 +138,7 @@ export default function AutomationEntry({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const boxContent = (
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'transparent',
|
backgroundColor: shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'transparent',
|
||||||
@ -132,6 +181,17 @@ export default function AutomationEntry({
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
{onCopy && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<ContentCopyIcon style={{ fontSize: 16 }} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCopy();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('automate.copyToSaved', 'Copy to Saved')}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<EditIcon style={{ fontSize: 16 }} />}
|
leftSection={<EditIcon style={{ fontSize: 16 }} />}
|
||||||
@ -160,4 +220,18 @@ export default function AutomationEntry({
|
|||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Only show tooltip if description exists, otherwise return plain content
|
||||||
|
return description ? (
|
||||||
|
<Tooltip
|
||||||
|
content={createTooltipContent()}
|
||||||
|
position="right"
|
||||||
|
arrow={true}
|
||||||
|
delay={500}
|
||||||
|
>
|
||||||
|
{boxContent}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
boxContent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
|
|||||||
import SettingsIcon from "@mui/icons-material/Settings";
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
import AutomationEntry from "./AutomationEntry";
|
import AutomationEntry from "./AutomationEntry";
|
||||||
import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations";
|
import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations";
|
||||||
import { AutomationConfig } from "../../../types/automation";
|
import { AutomationConfig, SuggestedAutomation } from "../../../types/automation";
|
||||||
|
|
||||||
interface AutomationSelectionProps {
|
interface AutomationSelectionProps {
|
||||||
savedAutomations: AutomationConfig[];
|
savedAutomations: AutomationConfig[];
|
||||||
@ -13,6 +13,7 @@ interface AutomationSelectionProps {
|
|||||||
onRun: (automation: AutomationConfig) => void;
|
onRun: (automation: AutomationConfig) => void;
|
||||||
onEdit: (automation: AutomationConfig) => void;
|
onEdit: (automation: AutomationConfig) => void;
|
||||||
onDelete: (automation: AutomationConfig) => void;
|
onDelete: (automation: AutomationConfig) => void;
|
||||||
|
onCopyFromSuggested: (automation: SuggestedAutomation) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AutomationSelection({
|
export default function AutomationSelection({
|
||||||
@ -20,7 +21,8 @@ export default function AutomationSelection({
|
|||||||
onCreateNew,
|
onCreateNew,
|
||||||
onRun,
|
onRun,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete
|
onDelete,
|
||||||
|
onCopyFromSuggested
|
||||||
}: AutomationSelectionProps) {
|
}: AutomationSelectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const suggestedAutomations = useSuggestedAutomations();
|
const suggestedAutomations = useSuggestedAutomations();
|
||||||
@ -63,9 +65,13 @@ export default function AutomationSelection({
|
|||||||
{suggestedAutomations.map((automation) => (
|
{suggestedAutomations.map((automation) => (
|
||||||
<AutomationEntry
|
<AutomationEntry
|
||||||
key={automation.id}
|
key={automation.id}
|
||||||
|
title={automation.name}
|
||||||
|
description={automation.description}
|
||||||
badgeIcon={automation.icon}
|
badgeIcon={automation.icon}
|
||||||
operations={automation.operations.map(op => op.operation)}
|
operations={automation.operations.map(op => op.operation)}
|
||||||
onClick={() => onRun(automation)}
|
onClick={() => onRun(automation)}
|
||||||
|
showMenu={true}
|
||||||
|
onCopy={() => onCopyFromSuggested(automation)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text, Stack, Group, ActionIcon } from '@mantine/core';
|
import { Text, Stack, Group, ActionIcon } from "@mantine/core";
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
|
||||||
import AddCircleOutline from '@mui/icons-material/AddCircleOutline';
|
import { AutomationTool } from "../../../types/automation";
|
||||||
import { AutomationTool } from '../../../types/automation';
|
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
||||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
import ToolSelector from "./ToolSelector";
|
||||||
import ToolSelector from './ToolSelector';
|
import AutomationEntry from "./AutomationEntry";
|
||||||
import AutomationEntry from './AutomationEntry';
|
|
||||||
|
|
||||||
interface ToolListProps {
|
interface ToolListProps {
|
||||||
tools: AutomationTool[];
|
tools: AutomationTool[];
|
||||||
@ -29,35 +28,39 @@ export default function ToolList({
|
|||||||
onToolConfigure,
|
onToolConfigure,
|
||||||
onToolAdd,
|
onToolAdd,
|
||||||
getToolName,
|
getToolName,
|
||||||
getToolDefaultParameters
|
getToolDefaultParameters,
|
||||||
}: ToolListProps) {
|
}: ToolListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleToolSelect = (index: number, newOperation: string) => {
|
const handleToolSelect = (index: number, newOperation: string) => {
|
||||||
const defaultParams = getToolDefaultParameters(newOperation);
|
const defaultParams = getToolDefaultParameters(newOperation);
|
||||||
|
|
||||||
onToolUpdate(index, {
|
onToolUpdate(index, {
|
||||||
operation: newOperation,
|
operation: newOperation,
|
||||||
name: getToolName(newOperation),
|
name: getToolName(newOperation),
|
||||||
configured: false,
|
configured: false,
|
||||||
parameters: defaultParams
|
parameters: defaultParams,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" fw={500} mb="xs" style={{ color: 'var(--mantine-color-text)' }}>
|
<Text size="sm" fw={500} mb="xs" style={{ color: "var(--mantine-color-text)" }}>
|
||||||
{t('automate.creation.tools.selected', 'Selected Tools')} ({tools.length})
|
{t("automate.creation.tools.selected", "Selected Tools")} ({tools.length})
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap="0">
|
<Stack gap="0">
|
||||||
{tools.map((tool, index) => (
|
{tools.map((tool, index) => (
|
||||||
<React.Fragment key={tool.id}>
|
<React.Fragment key={tool.id}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--mantine-color-gray-2)',
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
borderRadius: 'var(--mantine-radius-sm)',
|
borderRadius: tool.operation && !tool.configured
|
||||||
position: 'relative',
|
? "var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0"
|
||||||
padding: 'var(--mantine-spacing-xs)'
|
: "var(--mantine-radius-lg)",
|
||||||
|
backgroundColor: "var(--mantine-color-gray-2)",
|
||||||
|
position: "relative",
|
||||||
|
padding: "var(--mantine-spacing-xs)",
|
||||||
|
borderBottomWidth: tool.operation && !tool.configured ? "0" : "1px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Delete X in top right */}
|
{/* Delete X in top right */}
|
||||||
@ -65,26 +68,26 @@ export default function ToolList({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="xs"
|
size="xs"
|
||||||
onClick={() => onToolRemove(index)}
|
onClick={() => onToolRemove(index)}
|
||||||
title={t('automate.creation.tools.remove', 'Remove tool')}
|
title={t("automate.creation.tools.remove", "Remove tool")}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: '4px',
|
top: "4px",
|
||||||
right: '4px',
|
right: "4px",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
color: 'var(--mantine-color-gray-6)'
|
color: "var(--mantine-color-gray-6)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CloseIcon style={{ fontSize: 12 }} />
|
<CloseIcon style={{ fontSize: 16 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<div style={{ paddingRight: '1.25rem' }}>
|
<div style={{ paddingRight: "1.25rem" }}>
|
||||||
{/* Tool Selection Dropdown with inline settings cog */}
|
{/* Tool Selection Dropdown with inline settings cog */}
|
||||||
<Group gap="xs" align="center" wrap="nowrap">
|
<Group gap="xs" align="center" wrap="nowrap">
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<ToolSelector
|
<ToolSelector
|
||||||
key={`tool-selector-${tool.id}`}
|
key={`tool-selector-${tool.id}`}
|
||||||
onSelect={(newOperation) => handleToolSelect(index, newOperation)}
|
onSelect={(newOperation) => handleToolSelect(index, newOperation)}
|
||||||
excludeTools={['automate']}
|
excludeTools={["automate"]}
|
||||||
toolRegistry={toolRegistry}
|
toolRegistry={toolRegistry}
|
||||||
selectedValue={tool.operation}
|
selectedValue={tool.operation}
|
||||||
placeholder={tool.name}
|
placeholder={tool.name}
|
||||||
@ -97,26 +100,37 @@ export default function ToolList({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onToolConfigure(index)}
|
onClick={() => onToolConfigure(index)}
|
||||||
title={t('automate.creation.tools.configure', 'Configure tool')}
|
title={t("automate.creation.tools.configure", "Configure tool")}
|
||||||
style={{ color: 'var(--mantine-color-gray-6)' }}
|
style={{ color: "var(--mantine-color-gray-6)" }}
|
||||||
>
|
>
|
||||||
<SettingsIcon style={{ fontSize: 16 }} />
|
<SettingsIcon style={{ fontSize: 16 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Configuration status underneath */}
|
|
||||||
{tool.operation && !tool.configured && (
|
|
||||||
<Text pl="md" size="xs" c="dimmed" mt="xs">
|
|
||||||
{t('automate.creation.tools.notConfigured', "! Not Configured")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Configuration status underneath */}
|
||||||
|
{tool.operation && !tool.configured && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
borderTop: "none",
|
||||||
|
borderRadius: "0 0 var(--mantine-radius-lg) var(--mantine-radius-lg)",
|
||||||
|
backgroundColor: "var(--active-bg)",
|
||||||
|
padding: "var(--mantine-spacing-xs)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text pl="md" size="xs" >
|
||||||
|
{t("automate.creation.tools.notConfigured", "! Not Configured")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{index < tools.length - 1 && (
|
{index < tools.length - 1 && (
|
||||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
||||||
<Text size="xs" c="dimmed">↓</Text>
|
<Text size="xs" c="dimmed">
|
||||||
|
↓
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@ -124,19 +138,23 @@ export default function ToolList({
|
|||||||
|
|
||||||
{/* Arrow before Add Tool Button */}
|
{/* Arrow before Add Tool Button */}
|
||||||
{tools.length > 0 && (
|
{tools.length > 0 && (
|
||||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
||||||
<Text size="xs" c="dimmed">↓</Text>
|
<Text size="xs" c="dimmed">
|
||||||
|
↓
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Tool Button */}
|
{/* Add Tool Button */}
|
||||||
<div style={{
|
<div
|
||||||
border: '1px solid var(--mantine-color-gray-2)',
|
style={{
|
||||||
borderRadius: 'var(--mantine-radius-sm)',
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
overflow: 'hidden'
|
borderRadius: "var(--mantine-radius-sm)",
|
||||||
}}>
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AutomationEntry
|
<AutomationEntry
|
||||||
title={t('automate.creation.tools.addTool', 'Add Tool')}
|
title={t("automate.creation.tools.addTool", "Add Tool")}
|
||||||
badgeIcon={AddCircleOutline}
|
badgeIcon={AddCircleOutline}
|
||||||
operations={[]}
|
operations={[]}
|
||||||
onClick={onToolAdd}
|
onClick={onToolAdd}
|
||||||
@ -146,4 +164,4 @@ export default function ToolList({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Menu, Stack, Text, ScrollArea } from '@mantine/core';
|
import { Stack, Text, ScrollArea } from '@mantine/core';
|
||||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||||
import { useToolSections } from '../../../hooks/useToolSections';
|
import { useToolSections } from '../../../hooks/useToolSections';
|
||||||
import { renderToolButtons } from '../shared/renderToolButtons';
|
import { renderToolButtons } from '../shared/renderToolButtons';
|
||||||
import ToolSearch from '../toolPicker/ToolSearch';
|
import ToolSearch from '../toolPicker/ToolSearch';
|
||||||
|
import ToolButton from '../toolPicker/ToolButton';
|
||||||
|
|
||||||
interface ToolSelectorProps {
|
interface ToolSelectorProps {
|
||||||
onSelect: (toolKey: string) => void;
|
onSelect: (toolKey: string) => void;
|
||||||
@ -24,6 +25,8 @@ export default function ToolSelector({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [shouldAutoFocus, setShouldAutoFocus] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Filter out excluded tools (like 'automate' itself)
|
// Filter out excluded tools (like 'automate' itself)
|
||||||
const baseFilteredTools = useMemo(() => {
|
const baseFilteredTools = useMemo(() => {
|
||||||
@ -66,13 +69,21 @@ export default function ToolSelector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!sections || sections.length === 0) {
|
if (!sections || sections.length === 0) {
|
||||||
|
// If no sections, create a simple group from filtered tools
|
||||||
|
if (baseFilteredTools.length > 0) {
|
||||||
|
return [{
|
||||||
|
name: 'All Tools',
|
||||||
|
subcategoryId: 'all' as any,
|
||||||
|
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool }))
|
||||||
|
}];
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the "all" section which contains all tools without duplicates
|
// Find the "all" section which contains all tools without duplicates
|
||||||
const allSection = sections.find(s => (s as any).key === 'all');
|
const allSection = sections.find(s => (s as any).key === 'all');
|
||||||
return allSection?.subcategories || [];
|
return allSection?.subcategories || [];
|
||||||
}, [isSearching, searchGroups, sections]);
|
}, [isSearching, searchGroups, sections, baseFilteredTools]);
|
||||||
|
|
||||||
const handleToolSelect = useCallback((toolKey: string) => {
|
const handleToolSelect = useCallback((toolKey: string) => {
|
||||||
onSelect(toolKey);
|
onSelect(toolKey);
|
||||||
@ -88,8 +99,25 @@ export default function ToolSelector({
|
|||||||
|
|
||||||
const handleSearchFocus = () => {
|
const handleSearchFocus = () => {
|
||||||
setOpened(true);
|
setOpened(true);
|
||||||
|
setShouldAutoFocus(true); // Request auto-focus for the input
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle click outside to close dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setOpened(false);
|
||||||
|
setSearchTerm('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opened) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
setSearchTerm(value);
|
setSearchTerm(value);
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
@ -97,6 +125,14 @@ export default function ToolSelector({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInputFocus = () => {
|
||||||
|
if (!opened) {
|
||||||
|
setOpened(true);
|
||||||
|
}
|
||||||
|
// Clear auto-focus flag since input is now focused
|
||||||
|
setShouldAutoFocus(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Get display value for selected tool
|
// Get display value for selected tool
|
||||||
const getDisplayValue = () => {
|
const getDisplayValue = () => {
|
||||||
if (selectedValue && toolRegistry[selectedValue]) {
|
if (selectedValue && toolRegistry[selectedValue]) {
|
||||||
@ -106,77 +142,63 @@ export default function ToolSelector({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%' }}>
|
<div ref={containerRef} className='rounded-xl'>
|
||||||
<Menu
|
{/* Always show the target - either selected tool or search input */}
|
||||||
opened={opened}
|
|
||||||
onChange={(isOpen) => {
|
|
||||||
setOpened(isOpen);
|
|
||||||
// Clear search term when menu closes to show proper display
|
|
||||||
if (!isOpen) {
|
|
||||||
setSearchTerm('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
closeOnClickOutside={true}
|
|
||||||
closeOnEscape={true}
|
|
||||||
position="bottom-start"
|
|
||||||
offset={4}
|
|
||||||
withinPortal={false}
|
|
||||||
trapFocus={false}
|
|
||||||
shadow="sm"
|
|
||||||
transitionProps={{ duration: 0 }}
|
|
||||||
>
|
|
||||||
<Menu.Target>
|
|
||||||
<div style={{ width: '100%' }}>
|
|
||||||
{selectedValue && toolRegistry[selectedValue] && !opened ? (
|
|
||||||
// Show selected tool in AutomationEntry style when tool is selected and not searching
|
|
||||||
<div onClick={handleSearchFocus} style={{ cursor: 'pointer' }}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 'var(--mantine-spacing-sm)',
|
|
||||||
padding: '0 0.5rem',
|
|
||||||
borderRadius: 'var(--mantine-radius-sm)',
|
|
||||||
}}>
|
|
||||||
<div style={{ color: 'var(--mantine-color-text)', fontSize: '1.2rem' }}>
|
|
||||||
{toolRegistry[selectedValue].icon}
|
|
||||||
</div>
|
|
||||||
<Text size="sm" style={{ flex: 1, color: 'var(--mantine-color-text)' }}>
|
|
||||||
{toolRegistry[selectedValue].name}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Show search input when no tool selected or actively searching
|
|
||||||
<ToolSearch
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={handleSearchChange}
|
|
||||||
toolRegistry={filteredToolRegistry}
|
|
||||||
mode="filter"
|
|
||||||
placeholder={getDisplayValue()}
|
|
||||||
hideIcon={true}
|
|
||||||
onFocus={handleSearchFocus}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Menu.Target>
|
|
||||||
|
|
||||||
<Menu.Dropdown p={0} style={{ minWidth: '16rem' }}>
|
{selectedValue && toolRegistry[selectedValue] && !opened ? (
|
||||||
<ScrollArea h={350}>
|
// Show selected tool in AutomationEntry style when tool is selected and dropdown closed
|
||||||
<Stack gap="sm" p="sm">
|
<div onClick={handleSearchFocus} style={{ cursor: 'pointer',
|
||||||
{displayGroups.length === 0 ? (
|
borderRadius: "var(--mantine-radius-lg)" }}>
|
||||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
<ToolButton id='tool' tool={toolRegistry[selectedValue]} isSelected={false}
|
||||||
{isSearching
|
onSelect={()=>{}} rounded={true}></ToolButton>
|
||||||
? t('tools.noSearchResults', 'No tools found')
|
</div>
|
||||||
: t('tools.noTools', 'No tools available')
|
) : (
|
||||||
}
|
// Show search input when no tool selected OR when dropdown is opened
|
||||||
</Text>
|
<ToolSearch
|
||||||
) : (
|
value={searchTerm}
|
||||||
renderedTools
|
onChange={handleSearchChange}
|
||||||
)}
|
toolRegistry={filteredToolRegistry}
|
||||||
</Stack>
|
mode="unstyled"
|
||||||
</ScrollArea>
|
placeholder={getDisplayValue()}
|
||||||
</Menu.Dropdown>
|
hideIcon={true}
|
||||||
</Menu>
|
onFocus={handleInputFocus}
|
||||||
|
autoFocus={shouldAutoFocus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom dropdown */}
|
||||||
|
{opened && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
|
border: '1px solid var(--mantine-color-gray-3)',
|
||||||
|
borderRadius: 'var(--mantine-radius-sm)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
|
marginTop: '4px',
|
||||||
|
minWidth: '16rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScrollArea h={350}>
|
||||||
|
<Stack gap="sm" p="sm">
|
||||||
|
{displayGroups.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" p="md">
|
||||||
|
{isSearching
|
||||||
|
? t('tools.noSearchResults', 'No tools found')
|
||||||
|
: t('tools.noTools', 'No tools available')
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
renderedTools
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Text } from "@mantine/core";
|
import { Text, Anchor } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import FolderIcon from '@mui/icons-material/Folder';
|
||||||
|
import UploadIcon from '@mui/icons-material/Upload';
|
||||||
|
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
|
||||||
|
import { useAllFiles } from "../../../contexts/FileContext";
|
||||||
|
import { useFileManager } from "../../../hooks/useFileManager";
|
||||||
|
|
||||||
export interface FileStatusIndicatorProps {
|
export interface FileStatusIndicatorProps {
|
||||||
selectedFiles?: File[];
|
selectedFiles?: File[];
|
||||||
@ -14,15 +19,110 @@ const FileStatusIndicator = ({
|
|||||||
minFiles = 1,
|
minFiles = 1,
|
||||||
}: FileStatusIndicatorProps) => {
|
}: FileStatusIndicatorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const defaultPlaceholder = placeholder || t("files.placeholder", "Select a PDF file in the main view to get started");
|
const { openFilesModal, onFilesSelect } = useFilesModalContext();
|
||||||
|
const { files: workbenchFiles } = useAllFiles();
|
||||||
|
const { loadRecentFiles } = useFileManager();
|
||||||
|
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
|
||||||
|
|
||||||
// Only show content when no files are selected
|
// Check if there are recent files
|
||||||
|
useEffect(() => {
|
||||||
|
const checkRecentFiles = async () => {
|
||||||
|
try {
|
||||||
|
const recentFiles = await loadRecentFiles();
|
||||||
|
setHasRecentFiles(recentFiles.length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
setHasRecentFiles(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkRecentFiles();
|
||||||
|
}, [loadRecentFiles]);
|
||||||
|
|
||||||
|
// Handle native file picker
|
||||||
|
const handleNativeUpload = () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.multiple = true;
|
||||||
|
input.accept = '.pdf,application/pdf';
|
||||||
|
input.onchange = (event) => {
|
||||||
|
const files = Array.from((event.target as HTMLInputElement).files || []);
|
||||||
|
if (files.length > 0) {
|
||||||
|
onFilesSelect(files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't render until we know if there are recent files
|
||||||
|
if (hasRecentFiles === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are no files in the workbench
|
||||||
|
if (workbenchFiles.length === 0) {
|
||||||
|
// If no recent files, show upload button
|
||||||
|
if (!hasRecentFiles) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNativeUpload}
|
||||||
|
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||||
|
>
|
||||||
|
<UploadIcon style={{ fontSize: '0.875rem' }} />
|
||||||
|
{t("files.upload", "Upload")}
|
||||||
|
</Anchor>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// If there are recent files, show add files button
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
onClick={openFilesModal}
|
||||||
|
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||||
|
>
|
||||||
|
<FolderIcon style={{ fontSize: '0.875rem' }} />
|
||||||
|
{t("files.addFiles", "Add files")}
|
||||||
|
</Anchor>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show selection status when there are files in workbench
|
||||||
if (selectedFiles.length < minFiles) {
|
if (selectedFiles.length < minFiles) {
|
||||||
return (
|
// If no recent files, show upload option
|
||||||
<Text size="sm" c="dimmed">
|
if (!hasRecentFiles) {
|
||||||
{defaultPlaceholder}
|
return (
|
||||||
</Text>
|
<Text size="sm" c="dimmed">
|
||||||
);
|
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNativeUpload}
|
||||||
|
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||||
|
>
|
||||||
|
<UploadIcon style={{ fontSize: '0.875rem' }} />
|
||||||
|
{t("files.upload", "Upload")}
|
||||||
|
</Anchor>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// If there are recent files, show add files option
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
onClick={openFilesModal}
|
||||||
|
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||||
|
>
|
||||||
|
<FolderIcon style={{ fontSize: '0.875rem' }} />
|
||||||
|
{t("files.addFiles", "Add files")}
|
||||||
|
</Anchor>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import React, { createContext, useContext, useMemo, useRef } from 'react';
|
import React, { createContext, useContext, useMemo, useRef } from 'react';
|
||||||
import { Text, Stack, Box, Flex, Divider } from '@mantine/core';
|
import { Text, Stack, Box, Flex, Divider } from '@mantine/core';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import LocalIcon from '../../shared/LocalIcon';
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
|
||||||
import { Tooltip } from '../../shared/Tooltip';
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
import { TooltipTip } from '../../../types/tips';
|
import { TooltipTip } from '../../../types/tips';
|
||||||
import { createFilesToolStep, FilesToolStepProps } from './FilesToolStep';
|
import { createFilesToolStep, FilesToolStepProps } from './FilesToolStep';
|
||||||
@ -25,6 +24,7 @@ export interface ToolStepProps {
|
|||||||
_stepNumber?: number; // Internal prop set by ToolStepContainer
|
_stepNumber?: number; // Internal prop set by ToolStepContainer
|
||||||
_excludeFromCount?: boolean; // Internal prop to exclude from visible count calculation
|
_excludeFromCount?: boolean; // Internal prop to exclude from visible count calculation
|
||||||
_noPadding?: boolean; // Internal prop to exclude from default left padding
|
_noPadding?: boolean; // Internal prop to exclude from default left padding
|
||||||
|
alwaysShowTooltip?: boolean; // Force tooltip to show even when collapsed
|
||||||
tooltip?: {
|
tooltip?: {
|
||||||
content?: React.ReactNode;
|
content?: React.ReactNode;
|
||||||
tips?: TooltipTip[];
|
tips?: TooltipTip[];
|
||||||
@ -38,9 +38,10 @@ export interface ToolStepProps {
|
|||||||
const renderTooltipTitle = (
|
const renderTooltipTitle = (
|
||||||
title: string,
|
title: string,
|
||||||
tooltip: ToolStepProps['tooltip'],
|
tooltip: ToolStepProps['tooltip'],
|
||||||
isCollapsed: boolean
|
isCollapsed: boolean,
|
||||||
|
alwaysShowTooltip: boolean = false
|
||||||
) => {
|
) => {
|
||||||
if (tooltip && !isCollapsed) {
|
if (tooltip && (!isCollapsed || alwaysShowTooltip)) {
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={tooltip.content}
|
content={tooltip.content}
|
||||||
@ -52,9 +53,7 @@ const renderTooltipTitle = (
|
|||||||
<Text fw={500} size="lg">
|
<Text fw={500} size="lg">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<span className="material-symbols-rounded" style={{ fontSize: '1.2rem', color: 'var(--icon-files-color)' }}>
|
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
|
||||||
gpp_maybe
|
|
||||||
</span>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@ -77,6 +76,7 @@ const ToolStep = ({
|
|||||||
showNumber,
|
showNumber,
|
||||||
_stepNumber,
|
_stepNumber,
|
||||||
_noPadding,
|
_noPadding,
|
||||||
|
alwaysShowTooltip = false,
|
||||||
tooltip
|
tooltip
|
||||||
}: ToolStepProps) => {
|
}: ToolStepProps) => {
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
@ -118,18 +118,16 @@ const ToolStep = ({
|
|||||||
{stepNumber}
|
{stepNumber}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{renderTooltipTitle(title, tooltip, isCollapsed)}
|
{renderTooltipTitle(title, tooltip, isCollapsed, alwaysShowTooltip)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<ChevronRightIcon style={{
|
<LocalIcon icon="chevron-right-rounded" width="1.2rem" height="1.2rem" style={{
|
||||||
fontSize: '1.2rem',
|
|
||||||
color: 'var(--mantine-color-dimmed)',
|
color: 'var(--mantine-color-dimmed)',
|
||||||
opacity: onCollapsedClick ? 1 : 0.5
|
opacity: onCollapsedClick ? 1 : 0.5
|
||||||
}} />
|
}} />
|
||||||
) : (
|
) : (
|
||||||
<ExpandMoreIcon style={{
|
<LocalIcon icon="expand-more-rounded" width="1.2rem" height="1.2rem" style={{
|
||||||
fontSize: '1.2rem',
|
|
||||||
color: 'var(--mantine-color-dimmed)',
|
color: 'var(--mantine-color-dimmed)',
|
||||||
opacity: onCollapsedClick ? 1 : 0.5
|
opacity: onCollapsedClick ? 1 : 0.5
|
||||||
}} />
|
}} />
|
||||||
|
52
frontend/src/components/tools/shared/ToolWorkflowTitle.tsx
Normal file
52
frontend/src/components/tools/shared/ToolWorkflowTitle.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Flex, Text, Divider } from '@mantine/core';
|
||||||
|
import LocalIcon from '../../shared/LocalIcon';
|
||||||
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
|
|
||||||
|
export interface ToolWorkflowTitleProps {
|
||||||
|
title: string;
|
||||||
|
tooltip?: {
|
||||||
|
content?: React.ReactNode;
|
||||||
|
tips?: any[];
|
||||||
|
header?: {
|
||||||
|
title: string;
|
||||||
|
logo?: React.ReactNode;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolWorkflowTitle({ title, tooltip }: ToolWorkflowTitleProps) {
|
||||||
|
if (tooltip) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex justify="center" w="100%">
|
||||||
|
<Tooltip
|
||||||
|
content={tooltip.content}
|
||||||
|
tips={tooltip.tips}
|
||||||
|
header={tooltip.header}
|
||||||
|
sidebarTooltip={true}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Text fw={500} size="xl" p="md">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
|
||||||
|
</Flex>
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex justify="center" w="100%">
|
||||||
|
<Text fw={500} size="xl" p="md">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -3,6 +3,7 @@ import { Stack } from '@mantine/core';
|
|||||||
import { createToolSteps, ToolStepProvider } from './ToolStep';
|
import { createToolSteps, ToolStepProvider } from './ToolStep';
|
||||||
import OperationButton from './OperationButton';
|
import OperationButton from './OperationButton';
|
||||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||||
|
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
|
||||||
|
|
||||||
export interface FilesStepConfig {
|
export interface FilesStepConfig {
|
||||||
selectedFiles: File[];
|
selectedFiles: File[];
|
||||||
@ -46,7 +47,10 @@ export interface ReviewStepConfig {
|
|||||||
testId?: string;
|
testId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TitleConfig extends ToolWorkflowTitleProps {}
|
||||||
|
|
||||||
export interface ToolFlowConfig {
|
export interface ToolFlowConfig {
|
||||||
|
title?: TitleConfig;
|
||||||
files: FilesStepConfig;
|
files: FilesStepConfig;
|
||||||
steps: MiddleStepConfig[];
|
steps: MiddleStepConfig[];
|
||||||
executeButton?: ExecuteButtonConfig;
|
executeButton?: ExecuteButtonConfig;
|
||||||
@ -62,8 +66,11 @@ export function createToolFlow(config: ToolFlowConfig) {
|
|||||||
const steps = createToolSteps();
|
const steps = createToolSteps();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm" p="sm" h="95vh" w="100%" style={{ overflow: 'auto' }}>
|
<Stack gap="sm" p="sm" >
|
||||||
|
{/* <Stack gap="sm" p="sm" h="100%" w="100%" style={{ overflow: 'auto' }}> */}
|
||||||
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
|
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
|
||||||
|
{config.title && <ToolWorkflowTitle {...config.title} />}
|
||||||
|
|
||||||
{/* Files Step */}
|
{/* Files Step */}
|
||||||
{config.files.isVisible !== false && steps.createFilesStep({
|
{config.files.isVisible !== false && steps.createFilesStep({
|
||||||
selectedFiles: config.files.selectedFiles,
|
selectedFiles: config.files.selectedFiles,
|
||||||
|
@ -9,9 +9,10 @@ interface ToolButtonProps {
|
|||||||
tool: ToolRegistryEntry;
|
tool: ToolRegistryEntry;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
|
rounded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
|
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, rounded = false }) => {
|
||||||
const handleClick = (id: string) => {
|
const handleClick = (id: string) => {
|
||||||
if (tool.link) {
|
if (tool.link) {
|
||||||
// Open external link in new tab
|
// Open external link in new tab
|
||||||
@ -33,7 +34,17 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect
|
|||||||
fullWidth
|
fullWidth
|
||||||
justify="flex-start"
|
justify="flex-start"
|
||||||
className="tool-button"
|
className="tool-button"
|
||||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
|
styles={{
|
||||||
|
root: {
|
||||||
|
borderRadius: rounded ? 'var(--mantine-radius-lg)' : 0,
|
||||||
|
color: "var(--tools-text-and-icon-color)",
|
||||||
|
...(rounded && {
|
||||||
|
'&:hover': {
|
||||||
|
borderRadius: 'var(--mantine-radius-lg)',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FitText
|
<FitText
|
||||||
text={tool.name}
|
text={tool.name}
|
||||||
|
@ -76,4 +76,4 @@
|
|||||||
.search-input-container {
|
.search-input-container {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
@ -1,20 +1,22 @@
|
|||||||
import React, { useState, useRef, useEffect, useMemo } from "react";
|
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { Stack, Button, Text } from "@mantine/core";
|
import { Stack, Button, Text } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import LocalIcon from '../../shared/LocalIcon';
|
||||||
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
||||||
import { TextInput } from "../../shared/TextInput";
|
import { TextInput } from "../../shared/TextInput";
|
||||||
import './ToolPicker.css';
|
import "./ToolPicker.css";
|
||||||
|
|
||||||
interface ToolSearchProps {
|
interface ToolSearchProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
toolRegistry: Readonly<Record<string, ToolRegistryEntry>>;
|
toolRegistry: Readonly<Record<string, ToolRegistryEntry>>;
|
||||||
onToolSelect?: (toolId: string) => void;
|
onToolSelect?: (toolId: string) => void;
|
||||||
mode: 'filter' | 'dropdown';
|
mode: "filter" | "dropdown" | "unstyled";
|
||||||
selectedToolKey?: string | null;
|
selectedToolKey?: string | null;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
hideIcon?: boolean;
|
hideIcon?: boolean;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolSearch = ({
|
const ToolSearch = ({
|
||||||
@ -22,11 +24,12 @@ const ToolSearch = ({
|
|||||||
onChange,
|
onChange,
|
||||||
toolRegistry,
|
toolRegistry,
|
||||||
onToolSelect,
|
onToolSelect,
|
||||||
mode = 'filter',
|
mode = "filter",
|
||||||
selectedToolKey,
|
selectedToolKey,
|
||||||
placeholder,
|
placeholder,
|
||||||
hideIcon = false,
|
hideIcon = false,
|
||||||
onFocus
|
onFocus,
|
||||||
|
autoFocus = false,
|
||||||
}: ToolSearchProps) => {
|
}: ToolSearchProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
@ -37,9 +40,10 @@ const ToolSearch = ({
|
|||||||
if (!value.trim()) return [];
|
if (!value.trim()) return [];
|
||||||
return Object.entries(toolRegistry)
|
return Object.entries(toolRegistry)
|
||||||
.filter(([id, tool]) => {
|
.filter(([id, tool]) => {
|
||||||
if (mode === 'dropdown' && id === selectedToolKey) return false;
|
if (mode === "dropdown" && id === selectedToolKey) return false;
|
||||||
return tool.name.toLowerCase().includes(value.toLowerCase()) ||
|
return (
|
||||||
tool.description.toLowerCase().includes(value.toLowerCase());
|
tool.name.toLowerCase().includes(value.toLowerCase()) || tool.description.toLowerCase().includes(value.toLowerCase())
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map(([id, tool]) => ({ id, tool }));
|
.map(([id, tool]) => ({ id, tool }));
|
||||||
@ -47,7 +51,7 @@ const ToolSearch = ({
|
|||||||
|
|
||||||
const handleSearchChange = (searchValue: string) => {
|
const handleSearchChange = (searchValue: string) => {
|
||||||
onChange(searchValue);
|
onChange(searchValue);
|
||||||
if (mode === 'dropdown') {
|
if (mode === "dropdown") {
|
||||||
setDropdownOpen(searchValue.trim().length > 0 && filteredTools.length > 0);
|
setDropdownOpen(searchValue.trim().length > 0 && filteredTools.length > 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -63,49 +67,60 @@ const ToolSearch = ({
|
|||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Auto-focus the input when requested
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus && searchRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
searchRef.current?.focus();
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
}, [autoFocus]);
|
||||||
|
|
||||||
const searchInput = (
|
const searchInput = (
|
||||||
<div className="search-input-container">
|
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={searchRef}
|
ref={searchRef}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
|
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
|
||||||
icon={hideIcon ? undefined : <span className="material-symbols-rounded">search</span>}
|
icon={hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
onFocus={onFocus}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mode === 'filter') {
|
if (mode === "filter") {
|
||||||
|
return <div className="search-input-container">{searchInput}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "unstyled") {
|
||||||
return searchInput;
|
return searchInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={searchRef} style={{ position: 'relative' }}>
|
<div ref={searchRef} style={{ position: "relative" }}>
|
||||||
{searchInput}
|
{searchInput}
|
||||||
{dropdownOpen && filteredTools.length > 0 && (
|
{dropdownOpen && filteredTools.length > 0 && (
|
||||||
<div
|
<div
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: '100%',
|
top: "100%",
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
backgroundColor: 'var(--mantine-color-body)',
|
backgroundColor: "var(--mantine-color-body)",
|
||||||
border: '1px solid var(--mantine-color-gray-3)',
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
borderRadius: '6px',
|
borderRadius: "6px",
|
||||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
|
||||||
maxHeight: '300px',
|
maxHeight: "300px",
|
||||||
overflowY: 'auto'
|
overflowY: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack gap="xs" style={{ padding: '8px' }}>
|
<Stack gap="xs" style={{ padding: "8px" }}>
|
||||||
{filteredTools.map(({ id, tool }) => (
|
{filteredTools.map(({ id, tool }) => (
|
||||||
<Button
|
<Button
|
||||||
key={id}
|
key={id}
|
||||||
@ -114,22 +129,18 @@ const ToolSearch = ({
|
|||||||
onToolSelect && onToolSelect(id);
|
onToolSelect && onToolSelect(id);
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
leftSection={
|
leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
|
||||||
<div style={{ color: 'var(--tools-text-and-icon-color)' }}>
|
|
||||||
{tool.icon}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
fullWidth
|
fullWidth
|
||||||
justify="flex-start"
|
justify="flex-start"
|
||||||
style={{
|
style={{
|
||||||
borderRadius: '6px',
|
borderRadius: "6px",
|
||||||
color: 'var(--tools-text-and-icon-color)',
|
color: "var(--tools-text-and-icon-color)",
|
||||||
padding: '8px 12px'
|
padding: "8px 12px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ textAlign: 'left' }}>
|
<div style={{ textAlign: "left" }}>
|
||||||
<div style={{ fontWeight: 500 }}>{tool.name}</div>
|
<div style={{ fontWeight: 500 }}>{tool.name}</div>
|
||||||
<Text size="xs" c="dimmed" style={{ marginTop: '2px' }}>
|
<Text size="xs" c="dimmed" style={{ marginTop: "2px" }}>
|
||||||
{tool.description}
|
{tool.description}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
34
frontend/src/components/tooltips/useAdvancedOCRTips.ts
Normal file
34
frontend/src/components/tooltips/useAdvancedOCRTips.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { TooltipContent } from '../../types/tips';
|
||||||
|
|
||||||
|
export const useAdvancedOCRTips = (): TooltipContent => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: {
|
||||||
|
title: t("ocr.tooltip.advanced.header.title", "Advanced OCR Processing"),
|
||||||
|
},
|
||||||
|
tips: [
|
||||||
|
{
|
||||||
|
title: t("ocr.tooltip.advanced.compatibility.title", "Compatibility Mode"),
|
||||||
|
description: t("ocr.tooltip.advanced.compatibility.text", "Uses OCR 'sandwich PDF' mode: results in larger files, but more reliable with certain languages and older PDF software. By default we use hOCR for smaller, modern PDFs.")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("ocr.tooltip.advanced.sidecar.title", "Create Text File"),
|
||||||
|
description: t("ocr.tooltip.advanced.sidecar.text", "Generates a separate .txt file alongside the PDF containing all extracted text content for easy access and processing.")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("ocr.tooltip.advanced.deskew.title", "Deskew Pages"),
|
||||||
|
description: t("ocr.tooltip.advanced.deskew.text", "Automatically corrects skewed or tilted pages to improve OCR accuracy. Useful for scanned documents that weren't perfectly aligned.")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("ocr.tooltip.advanced.clean.title", "Clean Input File"),
|
||||||
|
description: t("ocr.tooltip.advanced.clean.text", "Preprocesses the input by removing noise, enhancing contrast, and optimising the image for better OCR recognition before processing.")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("ocr.tooltip.advanced.cleanFinal.title", "Clean Final Output"),
|
||||||
|
description: t("ocr.tooltip.advanced.cleanFinal.text", "Post-processes the final PDF by removing OCR artefacts and optimising the text layer for better readability and smaller file size.")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
64
frontend/src/contexts/RightRailContext.tsx
Normal file
64
frontend/src/contexts/RightRailContext.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||||
|
import { RightRailAction, RightRailButtonConfig } from '../types/rightRail';
|
||||||
|
|
||||||
|
interface RightRailContextValue {
|
||||||
|
buttons: RightRailButtonConfig[];
|
||||||
|
actions: Record<string, RightRailAction>;
|
||||||
|
registerButtons: (buttons: RightRailButtonConfig[]) => void;
|
||||||
|
unregisterButtons: (ids: string[]) => void;
|
||||||
|
setAction: (id: string, action: RightRailAction) => void;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RightRailContext = createContext<RightRailContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function RightRailProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [buttons, setButtons] = useState<RightRailButtonConfig[]>([]);
|
||||||
|
const [actions, setActions] = useState<Record<string, RightRailAction>>({});
|
||||||
|
|
||||||
|
const registerButtons = useCallback((newButtons: RightRailButtonConfig[]) => {
|
||||||
|
setButtons(prev => {
|
||||||
|
const byId = new Map(prev.map(b => [b.id, b] as const));
|
||||||
|
newButtons.forEach(nb => {
|
||||||
|
const existing = byId.get(nb.id) || ({} as RightRailButtonConfig);
|
||||||
|
byId.set(nb.id, { ...existing, ...nb });
|
||||||
|
});
|
||||||
|
const merged = Array.from(byId.values());
|
||||||
|
merged.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id));
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
const ids = newButtons.map(b => b.id);
|
||||||
|
const dupes = ids.filter((id, idx) => ids.indexOf(id) !== idx);
|
||||||
|
if (dupes.length) console.warn('[RightRail] Duplicate ids in registerButtons:', dupes);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unregisterButtons = useCallback((ids: string[]) => {
|
||||||
|
setButtons(prev => prev.filter(b => !ids.includes(b.id)));
|
||||||
|
setActions(prev => Object.fromEntries(Object.entries(prev).filter(([id]) => !ids.includes(id))));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAction = useCallback((id: string, action: RightRailAction) => {
|
||||||
|
setActions(prev => ({ ...prev, [id]: action }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setButtons([]);
|
||||||
|
setActions({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<RightRailContextValue>(() => ({ buttons, actions, registerButtons, unregisterButtons, setAction, clear }), [buttons, actions, registerButtons, unregisterButtons, setAction, clear]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RightRailContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</RightRailContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRightRail() {
|
||||||
|
const ctx = useContext(RightRailContext);
|
||||||
|
if (!ctx) throw new Error('useRightRail must be used within RightRailProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
@ -134,7 +134,10 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
|||||||
|
|
||||||
const setPreviewFile = useCallback((file: File | null) => {
|
const setPreviewFile = useCallback((file: File | null) => {
|
||||||
dispatch({ type: 'SET_PREVIEW_FILE', payload: file });
|
dispatch({ type: 'SET_PREVIEW_FILE', payload: file });
|
||||||
}, []);
|
if (file) {
|
||||||
|
actions.setMode('viewer');
|
||||||
|
}
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
const setPageEditorFunctions = useCallback((functions: PageEditorFunctions | null) => {
|
const setPageEditorFunctions = useCallback((functions: PageEditorFunctions | null) => {
|
||||||
dispatch({ type: 'SET_PAGE_EDITOR_FUNCTIONS', payload: functions });
|
dispatch({ type: 'SET_PAGE_EDITOR_FUNCTIONS', payload: functions });
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import LocalIcon from '../components/shared/LocalIcon';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import SplitPdfPanel from "../tools/Split";
|
import SplitPdfPanel from "../tools/Split";
|
||||||
import CompressPdfPanel from "../tools/Compress";
|
import CompressPdfPanel from "../tools/Compress";
|
||||||
@ -53,7 +54,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
// Signing
|
// Signing
|
||||||
|
|
||||||
"certSign": {
|
"certSign": {
|
||||||
icon: <span className="material-symbols-rounded">workspace_premium</span>,
|
icon: <LocalIcon icon="workspace-premium-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.certSign.title", "Sign with Certificate"),
|
name: t("home.certSign.title", "Sign with Certificate"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "sign",
|
view: "sign",
|
||||||
@ -62,7 +63,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.SIGNING
|
subcategoryId: SubcategoryId.SIGNING
|
||||||
},
|
},
|
||||||
"sign": {
|
"sign": {
|
||||||
icon: <span className="material-symbols-rounded">signature</span>,
|
icon: <LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.sign.title", "Sign"),
|
name: t("home.sign.title", "Sign"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "sign",
|
view: "sign",
|
||||||
@ -75,7 +76,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
// Document Security
|
// Document Security
|
||||||
|
|
||||||
"addPassword": {
|
"addPassword": {
|
||||||
icon: <span className="material-symbols-rounded">password</span>,
|
icon: <LocalIcon icon="password-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.addPassword.title", "Add Password"),
|
name: t("home.addPassword.title", "Add Password"),
|
||||||
component: AddPassword,
|
component: AddPassword,
|
||||||
view: "security",
|
view: "security",
|
||||||
@ -88,7 +89,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
settingsComponent: AddPasswordSettings
|
settingsComponent: AddPasswordSettings
|
||||||
},
|
},
|
||||||
"watermark": {
|
"watermark": {
|
||||||
icon: <span className="material-symbols-rounded">branding_watermark</span>,
|
icon: <LocalIcon icon="branding-watermark-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.watermark.title", "Add Watermark"),
|
name: t("home.watermark.title", "Add Watermark"),
|
||||||
component: AddWatermark,
|
component: AddWatermark,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -101,7 +102,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
settingsComponent: AddWatermarkSingleStepSettings
|
settingsComponent: AddWatermarkSingleStepSettings
|
||||||
},
|
},
|
||||||
"add-stamp": {
|
"add-stamp": {
|
||||||
icon: <span className="material-symbols-rounded">approval</span>,
|
icon: <LocalIcon icon="approval-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.AddStampRequest.title", "Add Stamp to PDF"),
|
name: t("home.AddStampRequest.title", "Add Stamp to PDF"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -110,7 +111,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
|
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
|
||||||
},
|
},
|
||||||
"sanitize": {
|
"sanitize": {
|
||||||
icon: <span className="material-symbols-rounded">cleaning_services</span>,
|
icon: <LocalIcon icon="cleaning-services-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.sanitize.title", "Sanitize"),
|
name: t("home.sanitize.title", "Sanitize"),
|
||||||
component: Sanitize,
|
component: Sanitize,
|
||||||
view: "security",
|
view: "security",
|
||||||
@ -123,7 +124,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
settingsComponent: SanitizeSettings
|
settingsComponent: SanitizeSettings
|
||||||
},
|
},
|
||||||
"flatten": {
|
"flatten": {
|
||||||
icon: <span className="material-symbols-rounded">layers_clear</span>,
|
icon: <LocalIcon icon="layers-clear-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.flatten.title", "Flatten"),
|
name: t("home.flatten.title", "Flatten"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -132,7 +133,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
|
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
|
||||||
},
|
},
|
||||||
"unlock-pdf-forms": {
|
"unlock-pdf-forms": {
|
||||||
icon: <span className="material-symbols-rounded">preview_off</span>,
|
icon: <LocalIcon icon="preview-off-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.unlockPDFForms.title", "Unlock PDF Forms"),
|
name: t("home.unlockPDFForms.title", "Unlock PDF Forms"),
|
||||||
component: UnlockPdfForms,
|
component: UnlockPdfForms,
|
||||||
view: "security",
|
view: "security",
|
||||||
@ -145,7 +146,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
settingsComponent: UnlockPdfFormsSettings
|
settingsComponent: UnlockPdfFormsSettings
|
||||||
},
|
},
|
||||||
"manage-certificates": {
|
"manage-certificates": {
|
||||||
icon: <span className="material-symbols-rounded">license</span>,
|
icon: <LocalIcon icon="license-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.manageCertificates.title", "Manage Certificates"),
|
name: t("home.manageCertificates.title", "Manage Certificates"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "security",
|
view: "security",
|
||||||
@ -154,7 +155,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
|
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
|
||||||
},
|
},
|
||||||
"change-permissions": {
|
"change-permissions": {
|
||||||
icon: <span className="material-symbols-rounded">lock</span>,
|
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.changePermissions.title", "Change Permissions"),
|
name: t("home.changePermissions.title", "Change Permissions"),
|
||||||
component: ChangePermissions,
|
component: ChangePermissions,
|
||||||
view: "security",
|
view: "security",
|
||||||
@ -169,7 +170,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
// Verification
|
// Verification
|
||||||
|
|
||||||
"get-all-info-on-pdf": {
|
"get-all-info-on-pdf": {
|
||||||
icon: <span className="material-symbols-rounded">fact_check</span>,
|
icon: <LocalIcon icon="fact-check-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.getPdfInfo.title", "Get ALL Info on PDF"),
|
name: t("home.getPdfInfo.title", "Get ALL Info on PDF"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "extract",
|
view: "extract",
|
||||||
@ -178,7 +179,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.VERIFICATION
|
subcategoryId: SubcategoryId.VERIFICATION
|
||||||
},
|
},
|
||||||
"validate-pdf-signature": {
|
"validate-pdf-signature": {
|
||||||
icon: <span className="material-symbols-rounded">verified</span>,
|
icon: <LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.validateSignature.title", "Validate PDF Signature"),
|
name: t("home.validateSignature.title", "Validate PDF Signature"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "security",
|
view: "security",
|
||||||
@ -191,7 +192,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
// Document Review
|
// Document Review
|
||||||
|
|
||||||
"read": {
|
"read": {
|
||||||
icon: <span className="material-symbols-rounded">article</span>,
|
icon: <LocalIcon icon="article-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.read.title", "Read"),
|
name: t("home.read.title", "Read"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "view",
|
view: "view",
|
||||||
@ -200,7 +201,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.DOCUMENT_REVIEW
|
subcategoryId: SubcategoryId.DOCUMENT_REVIEW
|
||||||
},
|
},
|
||||||
"change-metadata": {
|
"change-metadata": {
|
||||||
icon: <span className="material-symbols-rounded">assignment</span>,
|
icon: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.changeMetadata.title", "Change Metadata"),
|
name: t("home.changeMetadata.title", "Change Metadata"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -211,7 +212,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
// Page Formatting
|
// Page Formatting
|
||||||
|
|
||||||
"cropPdf": {
|
"cropPdf": {
|
||||||
icon: <span className="material-symbols-rounded">crop</span>,
|
icon: <LocalIcon icon="crop-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.crop.title", "Crop PDF"),
|
name: t("home.crop.title", "Crop PDF"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -220,7 +221,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||||
},
|
},
|
||||||
"rotate": {
|
"rotate": {
|
||||||
icon: <span className="material-symbols-rounded">rotate_right</span>,
|
icon: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.rotate.title", "Rotate"),
|
name: t("home.rotate.title", "Rotate"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -229,7 +230,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||||
},
|
},
|
||||||
"splitPdf": {
|
"splitPdf": {
|
||||||
icon: <span className="material-symbols-rounded">content_cut</span>,
|
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.split.title", "Split"),
|
name: t("home.split.title", "Split"),
|
||||||
component: SplitPdfPanel,
|
component: SplitPdfPanel,
|
||||||
view: "split",
|
view: "split",
|
||||||
@ -240,7 +241,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
settingsComponent: SplitSettings
|
settingsComponent: SplitSettings
|
||||||
},
|
},
|
||||||
"reorganize-pages": {
|
"reorganize-pages": {
|
||||||
icon: <span className="material-symbols-rounded">move_down</span>,
|
icon: <LocalIcon icon="move-down-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.reorganizePages.title", "Reorganize Pages"),
|
name: t("home.reorganizePages.title", "Reorganize Pages"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "pageEditor",
|
view: "pageEditor",
|
||||||
@ -249,7 +250,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||||
},
|
},
|
||||||
"adjust-page-size-scale": {
|
"adjust-page-size-scale": {
|
||||||
icon: <span className="material-symbols-rounded">crop_free</span>,
|
icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.scalePages.title", "Adjust page size/scale"),
|
name: t("home.scalePages.title", "Adjust page size/scale"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -258,7 +259,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||||
},
|
},
|
||||||
"addPageNumbers": {
|
"addPageNumbers": {
|
||||||
icon: <span className="material-symbols-rounded">123</span>,
|
icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.addPageNumbers.title", "Add Page Numbers"),
|
name: t("home.addPageNumbers.title", "Add Page Numbers"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -267,7 +268,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||||
},
|
},
|
||||||
"multi-page-layout": {
|
"multi-page-layout": {
|
||||||
icon: <span className="material-symbols-rounded">dashboard</span>,
|
icon: <LocalIcon icon="dashboard-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.pageLayout.title", "Multi-Page Layout"),
|
name: t("home.pageLayout.title", "Multi-Page Layout"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -276,7 +277,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
subcategoryId: SubcategoryId.PAGE_FORMATTING
|
||||||
},
|
},
|
||||||
"single-large-page": {
|
"single-large-page": {
|
||||||
icon: <span className="material-symbols-rounded">looks_one</span>,
|
icon: <LocalIcon icon="looks-one-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.pdfToSinglePage.title", "PDF to Single Large Page"),
|
name: t("home.pdfToSinglePage.title", "PDF to Single Large Page"),
|
||||||
component: SingleLargePage,
|
component: SingleLargePage,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -288,7 +289,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
operationConfig: singleLargePageOperationConfig
|
operationConfig: singleLargePageOperationConfig
|
||||||
},
|
},
|
||||||
"add-attachments": {
|
"add-attachments": {
|
||||||
icon: <span className="material-symbols-rounded">attachment</span>,
|
icon: <LocalIcon icon="attachment-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.attachments.title", "Add Attachments"),
|
name: t("home.attachments.title", "Add Attachments"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -301,7 +302,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
// Extraction
|
// Extraction
|
||||||
|
|
||||||
"extractPages": {
|
"extractPages": {
|
||||||
icon: <span className="material-symbols-rounded">upload</span>,
|
icon: <LocalIcon icon="upload-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.extractPages.title", "Extract Pages"),
|
name: t("home.extractPages.title", "Extract Pages"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "extract",
|
view: "extract",
|
||||||
@ -310,7 +311,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.EXTRACTION
|
subcategoryId: SubcategoryId.EXTRACTION
|
||||||
},
|
},
|
||||||
"extract-images": {
|
"extract-images": {
|
||||||
icon: <span className="material-symbols-rounded">filter</span>,
|
icon: <LocalIcon icon="filter-alt" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.extractImages.title", "Extract Images"),
|
name: t("home.extractImages.title", "Extract Images"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "extract",
|
view: "extract",
|
||||||
@ -323,7 +324,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
// Removal
|
// Removal
|
||||||
|
|
||||||
"removePages": {
|
"removePages": {
|
||||||
icon: <span className="material-symbols-rounded">delete</span>,
|
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.removePages.title", "Remove Pages"),
|
name: t("home.removePages.title", "Remove Pages"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "remove",
|
view: "remove",
|
||||||
@ -332,7 +333,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.REMOVAL
|
subcategoryId: SubcategoryId.REMOVAL
|
||||||
},
|
},
|
||||||
"remove-blank-pages": {
|
"remove-blank-pages": {
|
||||||
icon: <span className="material-symbols-rounded">scan_delete</span>,
|
icon: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.removeBlanks.title", "Remove Blank Pages"),
|
name: t("home.removeBlanks.title", "Remove Blank Pages"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "remove",
|
view: "remove",
|
||||||
@ -341,7 +342,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.REMOVAL
|
subcategoryId: SubcategoryId.REMOVAL
|
||||||
},
|
},
|
||||||
"remove-annotations": {
|
"remove-annotations": {
|
||||||
icon: <span className="material-symbols-rounded">thread_unread</span>,
|
icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.removeAnnotations.title", "Remove Annotations"),
|
name: t("home.removeAnnotations.title", "Remove Annotations"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "remove",
|
view: "remove",
|
||||||
@ -350,7 +351,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.REMOVAL
|
subcategoryId: SubcategoryId.REMOVAL
|
||||||
},
|
},
|
||||||
"remove-image": {
|
"remove-image": {
|
||||||
icon: <span className="material-symbols-rounded">remove_selection</span>,
|
icon: <LocalIcon icon="remove-selection-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.removeImagePdf.title", "Remove Image"),
|
name: t("home.removeImagePdf.title", "Remove Image"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -359,7 +360,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.REMOVAL
|
subcategoryId: SubcategoryId.REMOVAL
|
||||||
},
|
},
|
||||||
"remove-password": {
|
"remove-password": {
|
||||||
icon: <span className="material-symbols-rounded">lock_open_right</span>,
|
icon: <LocalIcon icon="lock-open-right-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.removePassword.title", "Remove Password"),
|
name: t("home.removePassword.title", "Remove Password"),
|
||||||
component: RemovePassword,
|
component: RemovePassword,
|
||||||
view: "security",
|
view: "security",
|
||||||
@ -372,7 +373,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
settingsComponent: RemovePasswordSettings
|
settingsComponent: RemovePasswordSettings
|
||||||
},
|
},
|
||||||
"remove-certificate-sign": {
|
"remove-certificate-sign": {
|
||||||
icon: <span className="material-symbols-rounded">remove_moderator</span>,
|
icon: <LocalIcon icon="remove-moderator-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.removeCertSign.title", "Remove Certificate Sign"),
|
name: t("home.removeCertSign.title", "Remove Certificate Sign"),
|
||||||
component: RemoveCertificateSign,
|
component: RemoveCertificateSign,
|
||||||
view: "security",
|
view: "security",
|
||||||
@ -388,7 +389,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
// Automation
|
// Automation
|
||||||
|
|
||||||
"automate": {
|
"automate": {
|
||||||
icon: <span className="material-symbols-rounded">automation</span>,
|
icon: <LocalIcon icon="automation-outline" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.automate.title", "Automate"),
|
name: t("home.automate.title", "Automate"),
|
||||||
component: React.lazy(() => import('../tools/Automate')),
|
component: React.lazy(() => import('../tools/Automate')),
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -399,7 +400,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
endpoints: ["handleData"]
|
endpoints: ["handleData"]
|
||||||
},
|
},
|
||||||
"auto-rename-pdf-file": {
|
"auto-rename-pdf-file": {
|
||||||
icon: <span className="material-symbols-rounded">match_word</span>,
|
icon: <LocalIcon icon="match-word-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.auto-rename.title", "Auto Rename PDF File"),
|
name: t("home.auto-rename.title", "Auto Rename PDF File"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -408,7 +409,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.AUTOMATION
|
subcategoryId: SubcategoryId.AUTOMATION
|
||||||
},
|
},
|
||||||
"auto-split-pages": {
|
"auto-split-pages": {
|
||||||
icon: <span className="material-symbols-rounded">split_scene_right</span>,
|
icon: <LocalIcon icon="split-scene-right-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.autoSplitPDF.title", "Auto Split Pages"),
|
name: t("home.autoSplitPDF.title", "Auto Split Pages"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -417,7 +418,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.AUTOMATION
|
subcategoryId: SubcategoryId.AUTOMATION
|
||||||
},
|
},
|
||||||
"auto-split-by-size-count": {
|
"auto-split-by-size-count": {
|
||||||
icon: <span className="material-symbols-rounded">content_cut</span>,
|
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.autoSizeSplitPDF.title", "Auto Split by Size/Count"),
|
name: t("home.autoSizeSplitPDF.title", "Auto Split by Size/Count"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -430,7 +431,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
// Advanced Formatting
|
// Advanced Formatting
|
||||||
|
|
||||||
"adjustContrast": {
|
"adjustContrast": {
|
||||||
icon: <span className="material-symbols-rounded">palette</span>,
|
icon: <LocalIcon icon="palette" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.adjustContrast.title", "Adjust Colors/Contrast"),
|
name: t("home.adjustContrast.title", "Adjust Colors/Contrast"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -439,7 +440,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
||||||
},
|
},
|
||||||
"repair": {
|
"repair": {
|
||||||
icon: <span className="material-symbols-rounded">build</span>,
|
icon: <LocalIcon icon="build-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.repair.title", "Repair"),
|
name: t("home.repair.title", "Repair"),
|
||||||
component: Repair,
|
component: Repair,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -452,7 +453,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
settingsComponent: RepairSettings
|
settingsComponent: RepairSettings
|
||||||
},
|
},
|
||||||
"detect-split-scanned-photos": {
|
"detect-split-scanned-photos": {
|
||||||
icon: <span className="material-symbols-rounded">scanner</span>,
|
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.ScannerImageSplit.title", "Detect & Split Scanned Photos"),
|
name: t("home.ScannerImageSplit.title", "Detect & Split Scanned Photos"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -461,7 +462,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
||||||
},
|
},
|
||||||
"overlay-pdfs": {
|
"overlay-pdfs": {
|
||||||
icon: <span className="material-symbols-rounded">layers</span>,
|
icon: <LocalIcon icon="layers-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.overlay-pdfs.title", "Overlay PDFs"),
|
name: t("home.overlay-pdfs.title", "Overlay PDFs"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -470,7 +471,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
||||||
},
|
},
|
||||||
"replace-and-invert-color": {
|
"replace-and-invert-color": {
|
||||||
icon: <span className="material-symbols-rounded">format_color_fill</span>,
|
icon: <LocalIcon icon="format-color-fill-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.replaceColorPdf.title", "Replace & Invert Color"),
|
name: t("home.replaceColorPdf.title", "Replace & Invert Color"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -479,7 +480,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
||||||
},
|
},
|
||||||
"add-image": {
|
"add-image": {
|
||||||
icon: <span className="material-symbols-rounded">image</span>,
|
icon: <LocalIcon icon="image-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.addImage.title", "Add Image"),
|
name: t("home.addImage.title", "Add Image"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -488,7 +489,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
||||||
},
|
},
|
||||||
"edit-table-of-contents": {
|
"edit-table-of-contents": {
|
||||||
icon: <span className="material-symbols-rounded">bookmark_add</span>,
|
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.editTableOfContents.title", "Edit Table of Contents"),
|
name: t("home.editTableOfContents.title", "Edit Table of Contents"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -497,7 +498,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
|
||||||
},
|
},
|
||||||
"scanner-effect": {
|
"scanner-effect": {
|
||||||
icon: <span className="material-symbols-rounded">scanner</span>,
|
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.fakeScan.title", "Scanner Effect"),
|
name: t("home.fakeScan.title", "Scanner Effect"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -510,7 +511,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
// Developer Tools
|
// Developer Tools
|
||||||
|
|
||||||
"show-javascript": {
|
"show-javascript": {
|
||||||
icon: <span className="material-symbols-rounded">javascript</span>,
|
icon: <LocalIcon icon="javascript-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.showJS.title", "Show JavaScript"),
|
name: t("home.showJS.title", "Show JavaScript"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "extract",
|
view: "extract",
|
||||||
@ -519,7 +520,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS
|
subcategoryId: SubcategoryId.DEVELOPER_TOOLS
|
||||||
},
|
},
|
||||||
"dev-api": {
|
"dev-api": {
|
||||||
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
|
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: '#2F7BF6' }} />,
|
||||||
name: t("home.devApi.title", "API"),
|
name: t("home.devApi.title", "API"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "external",
|
view: "external",
|
||||||
@ -529,7 +530,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html"
|
link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html"
|
||||||
},
|
},
|
||||||
"dev-folder-scanning": {
|
"dev-folder-scanning": {
|
||||||
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
|
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: '#2F7BF6' }} />,
|
||||||
name: t("home.devFolderScanning.title", "Automated Folder Scanning"),
|
name: t("home.devFolderScanning.title", "Automated Folder Scanning"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "external",
|
view: "external",
|
||||||
@ -539,7 +540,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/"
|
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/"
|
||||||
},
|
},
|
||||||
"dev-sso-guide": {
|
"dev-sso-guide": {
|
||||||
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
|
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: '#2F7BF6' }} />,
|
||||||
name: t("home.devSsoGuide.title", "SSO Guide"),
|
name: t("home.devSsoGuide.title", "SSO Guide"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "external",
|
view: "external",
|
||||||
@ -549,7 +550,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration",
|
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration",
|
||||||
},
|
},
|
||||||
"dev-airgapped": {
|
"dev-airgapped": {
|
||||||
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
|
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: '#2F7BF6' }} />,
|
||||||
name: t("home.devAirgapped.title", "Air-gapped Setup"),
|
name: t("home.devAirgapped.title", "Air-gapped Setup"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "external",
|
view: "external",
|
||||||
@ -562,7 +563,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
|
|
||||||
// Recommended Tools
|
// Recommended Tools
|
||||||
"compare": {
|
"compare": {
|
||||||
icon: <span className="material-symbols-rounded">compare</span>,
|
icon: <LocalIcon icon="compare-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.compare.title", "Compare"),
|
name: t("home.compare.title", "Compare"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "format",
|
view: "format",
|
||||||
@ -571,7 +572,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
subcategoryId: SubcategoryId.GENERAL
|
subcategoryId: SubcategoryId.GENERAL
|
||||||
},
|
},
|
||||||
"compress": {
|
"compress": {
|
||||||
icon: <span className="material-symbols-rounded">zoom_in_map</span>,
|
icon: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.compress.title", "Compress"),
|
name: t("home.compress.title", "Compress"),
|
||||||
component: CompressPdfPanel,
|
component: CompressPdfPanel,
|
||||||
view: "compress",
|
view: "compress",
|
||||||
@ -583,7 +584,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
settingsComponent: CompressSettings
|
settingsComponent: CompressSettings
|
||||||
},
|
},
|
||||||
"convert": {
|
"convert": {
|
||||||
icon: <span className="material-symbols-rounded">sync_alt</span>,
|
icon: <LocalIcon icon="sync-alt-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.convert.title", "Convert"),
|
name: t("home.convert.title", "Convert"),
|
||||||
component: ConvertPanel,
|
component: ConvertPanel,
|
||||||
view: "convert",
|
view: "convert",
|
||||||
@ -629,7 +630,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
settingsComponent: ConvertSettings
|
settingsComponent: ConvertSettings
|
||||||
},
|
},
|
||||||
"mergePdfs": {
|
"mergePdfs": {
|
||||||
icon: <span className="material-symbols-rounded">library_add</span>,
|
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.merge.title", "Merge"),
|
name: t("home.merge.title", "Merge"),
|
||||||
component: Merge,
|
component: Merge,
|
||||||
view: "merge",
|
view: "merge",
|
||||||
@ -642,7 +643,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
settingsComponent: MergeSettings
|
settingsComponent: MergeSettings
|
||||||
},
|
},
|
||||||
"multi-tool": {
|
"multi-tool": {
|
||||||
icon: <span className="material-symbols-rounded">dashboard_customize</span>,
|
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.multiTool.title", "Multi-Tool"),
|
name: t("home.multiTool.title", "Multi-Tool"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "pageEditor",
|
view: "pageEditor",
|
||||||
@ -652,7 +653,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
maxFiles: -1
|
maxFiles: -1
|
||||||
},
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
icon: <span className="material-symbols-rounded">quick_reference_all</span>,
|
icon: <LocalIcon icon="quick-reference-all-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.ocr.title", "OCR"),
|
name: t("home.ocr.title", "OCR"),
|
||||||
component: OCRPanel,
|
component: OCRPanel,
|
||||||
view: "convert",
|
view: "convert",
|
||||||
@ -664,7 +665,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
settingsComponent: OCRSettings
|
settingsComponent: OCRSettings
|
||||||
},
|
},
|
||||||
"redact": {
|
"redact": {
|
||||||
icon: <span className="material-symbols-rounded">visibility_off</span>,
|
icon: <LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.redact.title", "Redact"),
|
name: t("home.redact.title", "Redact"),
|
||||||
component: null,
|
component: null,
|
||||||
view: "redact",
|
view: "redact",
|
||||||
|
13
frontend/src/global.d.ts
vendored
13
frontend/src/global.d.ts
vendored
@ -4,4 +4,15 @@ declare module "../components/PageEditor";
|
|||||||
declare module "../components/Viewer";
|
declare module "../components/Viewer";
|
||||||
declare module "*.js";
|
declare module "*.js";
|
||||||
declare module '*.module.css';
|
declare module '*.module.css';
|
||||||
declare module 'pdfjs-dist';
|
declare module 'pdfjs-dist';
|
||||||
|
|
||||||
|
// Auto-generated icon set JSON import
|
||||||
|
declare module '../assets/material-symbols-icons.json' {
|
||||||
|
const value: {
|
||||||
|
prefix: string;
|
||||||
|
icons: Record<string, any>;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
export default value;
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation';
|
import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation';
|
||||||
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
|
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
|
||||||
@ -16,18 +16,18 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
|
|||||||
const [automationName, setAutomationName] = useState('');
|
const [automationName, setAutomationName] = useState('');
|
||||||
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
|
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
|
||||||
|
|
||||||
const getToolName = (operation: string) => {
|
const getToolName = useCallback((operation: string) => {
|
||||||
const tool = toolRegistry?.[operation] as any;
|
const tool = toolRegistry?.[operation] as any;
|
||||||
return tool?.name || t(`tools.${operation}.name`, operation);
|
return tool?.name || t(`tools.${operation}.name`, operation);
|
||||||
};
|
}, [toolRegistry, t]);
|
||||||
|
|
||||||
const getToolDefaultParameters = (operation: string): Record<string, any> => {
|
const getToolDefaultParameters = useCallback((operation: string): Record<string, any> => {
|
||||||
const config = toolRegistry[operation]?.operationConfig;
|
const config = toolRegistry[operation]?.operationConfig;
|
||||||
if (config?.defaultParameters) {
|
if (config?.defaultParameters) {
|
||||||
return { ...config.defaultParameters };
|
return { ...config.defaultParameters };
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
};
|
}, [toolRegistry]);
|
||||||
|
|
||||||
// Initialize based on mode and existing automation
|
// Initialize based on mode and existing automation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -58,7 +58,7 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
|
|||||||
}));
|
}));
|
||||||
setSelectedTools(defaultTools);
|
setSelectedTools(defaultTools);
|
||||||
}
|
}
|
||||||
}, [mode, existingAutomation, selectedTools.length, t, getToolName]);
|
}, [mode, existingAutomation, t, getToolName]);
|
||||||
|
|
||||||
const addTool = (operation: string) => {
|
const addTool = (operation: string) => {
|
||||||
const newTool: AutomationTool = {
|
const newTool: AutomationTool = {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { AutomationConfig } from '../../../services/automationStorage';
|
import { AutomationConfig } from '../../../services/automationStorage';
|
||||||
|
import { SuggestedAutomation } from '../../../types/automation';
|
||||||
|
|
||||||
export interface SavedAutomation extends AutomationConfig {}
|
export interface SavedAutomation extends AutomationConfig {}
|
||||||
|
|
||||||
@ -40,6 +41,26 @@ export function useSavedAutomations() {
|
|||||||
}
|
}
|
||||||
}, [refreshAutomations]);
|
}, [refreshAutomations]);
|
||||||
|
|
||||||
|
const copyFromSuggested = useCallback(async (suggestedAutomation: SuggestedAutomation) => {
|
||||||
|
try {
|
||||||
|
const { automationStorage } = await import('../../../services/automationStorage');
|
||||||
|
|
||||||
|
// Convert suggested automation to saved automation format
|
||||||
|
const savedAutomation = {
|
||||||
|
name: suggestedAutomation.name,
|
||||||
|
description: suggestedAutomation.description,
|
||||||
|
operations: suggestedAutomation.operations
|
||||||
|
};
|
||||||
|
|
||||||
|
await automationStorage.saveAutomation(savedAutomation);
|
||||||
|
// Refresh the list after saving
|
||||||
|
refreshAutomations();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error copying suggested automation:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [refreshAutomations]);
|
||||||
|
|
||||||
// Load automations on mount
|
// Load automations on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSavedAutomations();
|
loadSavedAutomations();
|
||||||
@ -50,6 +71,7 @@ export function useSavedAutomations() {
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refreshAutomations,
|
refreshAutomations,
|
||||||
deleteAutomation
|
deleteAutomation,
|
||||||
|
copyFromSuggested
|
||||||
};
|
};
|
||||||
}
|
}
|
@ -1,8 +1,15 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import StarIcon from '@mui/icons-material/Star';
|
import React from 'react';
|
||||||
|
import LocalIcon from '../../../components/shared/LocalIcon';
|
||||||
import { SuggestedAutomation } from '../../../types/automation';
|
import { SuggestedAutomation } from '../../../types/automation';
|
||||||
|
|
||||||
|
// Create icon components
|
||||||
|
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
|
||||||
|
const TextFieldsIcon = () => React.createElement(LocalIcon, { icon: 'text-fields', width: '1.5rem', height: '1.5rem' });
|
||||||
|
const SecurityIcon = () => React.createElement(LocalIcon, { icon: 'security', width: '1.5rem', height: '1.5rem' });
|
||||||
|
const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' });
|
||||||
|
|
||||||
export function useSuggestedAutomations(): SuggestedAutomation[] {
|
export function useSuggestedAutomations(): SuggestedAutomation[] {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@ -10,37 +17,171 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "compress-and-merge",
|
id: "secure-pdf-ingestion",
|
||||||
name: t("automation.suggested.compressAndMerge", "Compress & Merge"),
|
name: t("automation.suggested.securePdfIngestion", "Secure PDF Ingestion"),
|
||||||
description: t("automation.suggested.compressAndMergeDesc", "Compress PDFs and merge them into one file"),
|
description: t("automation.suggested.securePdfIngestionDesc", "Comprehensive PDF processing workflow that sanitizes documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimizes file size."),
|
||||||
operations: [
|
operations: [
|
||||||
{ operation: "compress", parameters: {} },
|
{
|
||||||
{ operation: "merge", parameters: {} }
|
operation: "sanitize",
|
||||||
|
parameters: {
|
||||||
|
removeJavaScript: true,
|
||||||
|
removeEmbeddedFiles: true,
|
||||||
|
removeXMPMetadata: true,
|
||||||
|
removeMetadata: true,
|
||||||
|
removeLinks: false,
|
||||||
|
removeFonts: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "ocr",
|
||||||
|
parameters: {
|
||||||
|
languages: ['eng'],
|
||||||
|
ocrType: 'skip-text',
|
||||||
|
ocrRenderType: 'hocr',
|
||||||
|
additionalOptions: ['clean', 'cleanFinal'],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "convert",
|
||||||
|
parameters: {
|
||||||
|
fromExtension: 'pdf',
|
||||||
|
toExtension: 'pdfa',
|
||||||
|
pdfaOptions: {
|
||||||
|
outputFormat: 'pdfa-1',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "compress",
|
||||||
|
parameters: {
|
||||||
|
compressionLevel: 5,
|
||||||
|
grayscale: false,
|
||||||
|
expectedSize: '',
|
||||||
|
compressionMethod: 'quality',
|
||||||
|
fileSizeValue: '',
|
||||||
|
fileSizeUnit: 'MB',
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
icon: StarIcon,
|
icon: SecurityIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ocr-and-convert",
|
id: "email-preparation",
|
||||||
name: t("automation.suggested.ocrAndConvert", "OCR & Convert"),
|
name: t("automation.suggested.emailPreparation", "Email Preparation"),
|
||||||
description: t("automation.suggested.ocrAndConvertDesc", "Extract text via OCR and convert to different format"),
|
description: t("automation.suggested.emailPreparationDesc", "Optimizes PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy."),
|
||||||
operations: [
|
operations: [
|
||||||
{ operation: "ocr", parameters: {} },
|
{
|
||||||
{ operation: "convert", parameters: {} }
|
operation: "compress",
|
||||||
|
parameters: {
|
||||||
|
compressionLevel: 5,
|
||||||
|
grayscale: false,
|
||||||
|
expectedSize: '',
|
||||||
|
compressionMethod: 'quality',
|
||||||
|
fileSizeValue: '',
|
||||||
|
fileSizeUnit: 'MB',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "splitPdf",
|
||||||
|
parameters: {
|
||||||
|
mode: 'bySizeOrCount',
|
||||||
|
pages: '',
|
||||||
|
hDiv: '1',
|
||||||
|
vDiv: '1',
|
||||||
|
merge: false,
|
||||||
|
splitType: 'size',
|
||||||
|
splitValue: '20MB',
|
||||||
|
bookmarkLevel: '1',
|
||||||
|
includeMetadata: false,
|
||||||
|
allowDuplicates: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "sanitize",
|
||||||
|
parameters: {
|
||||||
|
removeJavaScript: false,
|
||||||
|
removeEmbeddedFiles: false,
|
||||||
|
removeXMPMetadata: true,
|
||||||
|
removeMetadata: true,
|
||||||
|
removeLinks: false,
|
||||||
|
removeFonts: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
icon: StarIcon,
|
icon: CompressIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "secure-workflow",
|
id: "secure-workflow",
|
||||||
name: t("automation.suggested.secureWorkflow", "Secure Workflow"),
|
name: t("automation.suggested.secureWorkflow", "Security Workflow"),
|
||||||
description: t("automation.suggested.secureWorkflowDesc", "Sanitize, add password, and set permissions"),
|
description: t("automation.suggested.secureWorkflowDesc", "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access."),
|
||||||
operations: [
|
operations: [
|
||||||
{ operation: "sanitize", parameters: {} },
|
{
|
||||||
{ operation: "addPassword", parameters: {} },
|
operation: "sanitize",
|
||||||
{ operation: "changePermissions", parameters: {} }
|
parameters: {
|
||||||
|
removeJavaScript: true,
|
||||||
|
removeEmbeddedFiles: true,
|
||||||
|
removeXMPMetadata: false,
|
||||||
|
removeMetadata: false,
|
||||||
|
removeLinks: false,
|
||||||
|
removeFonts: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "addPassword",
|
||||||
|
parameters: {
|
||||||
|
password: 'password',
|
||||||
|
ownerPassword: '',
|
||||||
|
keyLength: 128,
|
||||||
|
permissions: {
|
||||||
|
preventAssembly: false,
|
||||||
|
preventExtractContent: false,
|
||||||
|
preventExtractForAccessibility: false,
|
||||||
|
preventFillInForm: false,
|
||||||
|
preventModify: false,
|
||||||
|
preventModifyAnnotations: false,
|
||||||
|
preventPrinting: false,
|
||||||
|
preventPrintingFaithful: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
icon: SecurityIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "process-images",
|
||||||
|
name: t("automation.suggested.processImages", "Process Images"),
|
||||||
|
description: t("automation.suggested.processImagesDesc", "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."),
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
operation: "convert",
|
||||||
|
parameters: {
|
||||||
|
fromExtension: 'image',
|
||||||
|
toExtension: 'pdf',
|
||||||
|
imageOptions: {
|
||||||
|
colorType: 'color',
|
||||||
|
dpi: 300,
|
||||||
|
singleOrMultiple: 'multiple',
|
||||||
|
fitOption: 'maintainAspectRatio',
|
||||||
|
autoRotate: true,
|
||||||
|
combineImages: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "ocr",
|
||||||
|
parameters: {
|
||||||
|
languages: ['eng'],
|
||||||
|
ocrType: 'skip-text',
|
||||||
|
ocrRenderType: 'hocr',
|
||||||
|
additionalOptions: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
@ -161,8 +161,8 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb
|
|||||||
}
|
}
|
||||||
lastToggleTime.current = currentTime;
|
lastToggleTime.current = currentTime;
|
||||||
|
|
||||||
// Easter egg: Activate rainbow mode after 6 rapid toggles
|
// Easter egg: Activate rainbow mode after 10 rapid toggles
|
||||||
if (toggleCount.current >= 6) {
|
if (toggleCount.current >= 10) {
|
||||||
setThemeMode('rainbow');
|
setThemeMode('rainbow');
|
||||||
console.log('🌈 RAINBOW MODE ACTIVATED! 🌈 You found the secret easter egg!');
|
console.log('🌈 RAINBOW MODE ACTIVATED! 🌈 You found the secret easter egg!');
|
||||||
console.log('🌈 Button will be disabled for 3 seconds, then click once to exit!');
|
console.log('🌈 Button will be disabled for 3 seconds, then click once to exit!');
|
||||||
|
46
frontend/src/hooks/useRightRailButtons.ts
Normal file
46
frontend/src/hooks/useRightRailButtons.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useRightRail } from '../contexts/RightRailContext';
|
||||||
|
import { RightRailAction, RightRailButtonConfig } from '../types/rightRail';
|
||||||
|
|
||||||
|
export interface RightRailButtonWithAction extends RightRailButtonConfig {
|
||||||
|
onClick: RightRailAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers one or more RightRail buttons and their actions.
|
||||||
|
* - Automatically registers on mount and unregisters on unmount
|
||||||
|
* - Updates registration when the input array reference changes
|
||||||
|
*/
|
||||||
|
export function useRightRailButtons(buttons: readonly RightRailButtonWithAction[]) {
|
||||||
|
const { registerButtons, unregisterButtons, setAction } = useRightRail();
|
||||||
|
|
||||||
|
// Memoize configs and ids to reduce churn
|
||||||
|
const configs: RightRailButtonConfig[] = useMemo(
|
||||||
|
() => buttons.map(({ onClick, ...cfg }) => cfg),
|
||||||
|
[buttons]
|
||||||
|
);
|
||||||
|
const ids: string[] = useMemo(() => buttons.map(b => b.id), [buttons]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!buttons || buttons.length === 0) return;
|
||||||
|
|
||||||
|
// DEV warnings for duplicate ids or missing handlers
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
const idSet = new Set<string>();
|
||||||
|
buttons.forEach(b => {
|
||||||
|
if (!b.onClick) console.warn('[RightRail] Missing onClick for id:', b.id);
|
||||||
|
if (idSet.has(b.id)) console.warn('[RightRail] Duplicate id in buttons array:', b.id);
|
||||||
|
idSet.add(b.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register visual button configs (idempotent merge by id)
|
||||||
|
registerButtons(configs);
|
||||||
|
|
||||||
|
// Bind/update actions independent of registration
|
||||||
|
buttons.forEach(({ id, onClick }) => setAction(id, onClick));
|
||||||
|
|
||||||
|
// Cleanup unregisters by ids present in this call
|
||||||
|
return () => unregisterButtons(ids);
|
||||||
|
}, [registerButtons, unregisterButtons, setAction, configs, ids, buttons]);
|
||||||
|
}
|
@ -1,9 +1,3 @@
|
|||||||
@import 'material-symbols/rounded.css';
|
|
||||||
|
|
||||||
.material-symbols-rounded {
|
|
||||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
@ -9,6 +9,7 @@ import { getBaseUrl } from "../constants/app";
|
|||||||
import ToolPanel from "../components/tools/ToolPanel";
|
import ToolPanel from "../components/tools/ToolPanel";
|
||||||
import Workbench from "../components/layout/Workbench";
|
import Workbench from "../components/layout/Workbench";
|
||||||
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
||||||
|
import RightRail from "../components/shared/RightRail";
|
||||||
import FileManager from "../components/FileManager";
|
import FileManager from "../components/FileManager";
|
||||||
|
|
||||||
|
|
||||||
@ -46,7 +47,8 @@ export default function HomePage() {
|
|||||||
ref={quickAccessRef} />
|
ref={quickAccessRef} />
|
||||||
<ToolPanel />
|
<ToolPanel />
|
||||||
<Workbench />
|
<Workbench />
|
||||||
|
<RightRail />
|
||||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -5,6 +5,7 @@ export interface ExportOptions {
|
|||||||
selectedOnly?: boolean;
|
selectedOnly?: boolean;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
splitDocuments?: boolean;
|
splitDocuments?: boolean;
|
||||||
|
appendSuffix?: boolean; // when false, do not append _edited/_selected
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PDFExportService {
|
export class PDFExportService {
|
||||||
@ -16,7 +17,7 @@ export class PDFExportService {
|
|||||||
selectedPageIds: string[] = [],
|
selectedPageIds: string[] = [],
|
||||||
options: ExportOptions = {}
|
options: ExportOptions = {}
|
||||||
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> {
|
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> {
|
||||||
const { selectedOnly = false, filename, splitDocuments = false } = options;
|
const { selectedOnly = false, filename, splitDocuments = false, appendSuffix = true } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine which pages to export
|
// Determine which pages to export
|
||||||
@ -36,7 +37,7 @@ export class PDFExportService {
|
|||||||
return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name);
|
return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name);
|
||||||
} else {
|
} else {
|
||||||
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
|
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
|
||||||
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly);
|
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, appendSuffix);
|
||||||
return { blob, filename: exportFilename };
|
return { blob, filename: exportFilename };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -56,7 +57,7 @@ export class PDFExportService {
|
|||||||
|
|
||||||
for (const page of pages) {
|
for (const page of pages) {
|
||||||
// Get the original page from source document
|
// Get the original page from source document
|
||||||
const sourcePageIndex = page.pageNumber - 1;
|
const sourcePageIndex = this.getOriginalSourceIndex(page);
|
||||||
|
|
||||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||||
// Copy the page
|
// Copy the page
|
||||||
@ -113,7 +114,7 @@ export class PDFExportService {
|
|||||||
const newDoc = await PDFLibDocument.create();
|
const newDoc = await PDFLibDocument.create();
|
||||||
|
|
||||||
for (const page of segmentPages) {
|
for (const page of segmentPages) {
|
||||||
const sourcePageIndex = page.pageNumber - 1;
|
const sourcePageIndex = this.getOriginalSourceIndex(page);
|
||||||
|
|
||||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||||
@ -146,11 +147,28 @@ export class PDFExportService {
|
|||||||
return { blobs, filenames };
|
return { blobs, filenames };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the original page index from a page's stable id.
|
||||||
|
* Falls back to the current pageNumber if parsing fails.
|
||||||
|
*/
|
||||||
|
private getOriginalSourceIndex(page: PDFPage): number {
|
||||||
|
const match = page.id.match(/-page-(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
const originalNumber = parseInt(match[1], 10);
|
||||||
|
if (!Number.isNaN(originalNumber)) {
|
||||||
|
return originalNumber - 1; // zero-based index for pdf-lib
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to the visible page number
|
||||||
|
return Math.max(0, page.pageNumber - 1);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate appropriate filename for export
|
* Generate appropriate filename for export
|
||||||
*/
|
*/
|
||||||
private generateFilename(originalName: string, selectedOnly: boolean): string {
|
private generateFilename(originalName: string, selectedOnly: boolean, appendSuffix: boolean): string {
|
||||||
const baseName = originalName.replace(/\.pdf$/i, '');
|
const baseName = originalName.replace(/\.pdf$/i, '');
|
||||||
|
if (!appendSuffix) return `${baseName}.pdf`;
|
||||||
const suffix = selectedOnly ? '_selected' : '_edited';
|
const suffix = selectedOnly ? '_selected' : '_edited';
|
||||||
return `${baseName}${suffix}.pdf`;
|
return `${baseName}${suffix}.pdf`;
|
||||||
}
|
}
|
||||||
|
@ -106,6 +106,12 @@
|
|||||||
--icon-config-bg: #9CA3AF;
|
--icon-config-bg: #9CA3AF;
|
||||||
--icon-config-color: #FFFFFF;
|
--icon-config-color: #FFFFFF;
|
||||||
|
|
||||||
|
/* RightRail (light) */
|
||||||
|
--right-rail-bg: #F5F6F8; /* light background */
|
||||||
|
--right-rail-foreground: #CDD4E1; /* panel behind custom tool icons */
|
||||||
|
--right-rail-icon: #4B5563; /* icon color */
|
||||||
|
--right-rail-icon-disabled: #CECECE;/* disabled icon */
|
||||||
|
|
||||||
/* Colors for tooltips */
|
/* Colors for tooltips */
|
||||||
--tooltip-title-bg: #DBEFFF;
|
--tooltip-title-bg: #DBEFFF;
|
||||||
--tooltip-title-color: #31528E;
|
--tooltip-title-color: #31528E;
|
||||||
@ -156,10 +162,22 @@
|
|||||||
--landing-drop-inner-paper-bg: #BBDEFB;
|
--landing-drop-inner-paper-bg: #BBDEFB;
|
||||||
--landing-drop-inner-paper-border: #90CAF9;
|
--landing-drop-inner-paper-border: #90CAF9;
|
||||||
|
|
||||||
|
/* selected file header colors */
|
||||||
|
--header-selected-bg: #1E88E5; /* light mode selected header matches dark */
|
||||||
|
--header-selected-fg: #FFFFFF;
|
||||||
|
--file-card-bg: #FFFFFF; /* file card background (light/dark paired) */
|
||||||
|
|
||||||
/* shadows */
|
/* shadows */
|
||||||
--drop-shadow-color: rgba(0, 0, 0, 0.08);
|
--drop-shadow-color: rgba(0, 0, 0, 0.08);
|
||||||
--drop-shadow-color-strong: rgba(0, 0, 0, 0.04);
|
--drop-shadow-color-strong: rgba(0, 0, 0, 0.04);
|
||||||
--drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.06)) drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.04));
|
--drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.06)) drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.04));
|
||||||
|
|
||||||
|
/* Light mode card hover and selection */
|
||||||
|
--header-hover-bg: #3B4B6E; /* same family as selected, a touch muted for hover */
|
||||||
|
--card-selected-border: #3FAFFF; /* slightly more blue than dark mode header */
|
||||||
|
--checkbox-border: #2F83BF;
|
||||||
|
--checkbox-checked-bg: #3FAFFF;
|
||||||
|
--checkbox-tick: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mantine-color-scheme="dark"] {
|
[data-mantine-color-scheme="dark"] {
|
||||||
@ -234,6 +252,12 @@
|
|||||||
--icon-inactive-bg: #2A2F36;
|
--icon-inactive-bg: #2A2F36;
|
||||||
--icon-inactive-color: #6E7581;
|
--icon-inactive-color: #6E7581;
|
||||||
|
|
||||||
|
/* RightRail (dark) */
|
||||||
|
--right-rail-bg: #1F2329; /* dark background */
|
||||||
|
--right-rail-foreground: #2A2F36; /* panel behind custom tool icons */
|
||||||
|
--right-rail-icon: #BCBEBF; /* icon color */
|
||||||
|
--right-rail-icon-disabled: #43464B;/* disabled icon */
|
||||||
|
|
||||||
/* Dark mode tooltip colors */
|
/* Dark mode tooltip colors */
|
||||||
--tooltip-title-bg: #4B525A;
|
--tooltip-title-bg: #4B525A;
|
||||||
--tooltip-title-color: #fff;
|
--tooltip-title-color: #fff;
|
||||||
@ -260,6 +284,12 @@
|
|||||||
--landing-drop-inner-paper-bg: #2A3441;
|
--landing-drop-inner-paper-bg: #2A3441;
|
||||||
--landing-drop-inner-paper-border: #3A4451;
|
--landing-drop-inner-paper-border: #3A4451;
|
||||||
|
|
||||||
|
/* selected file header colors for dark */
|
||||||
|
--header-selected-bg: #1E88E5;
|
||||||
|
--header-selected-fg: #FFFFFF;
|
||||||
|
/* file card background (dark) */
|
||||||
|
--file-card-bg: #1F2329;
|
||||||
|
|
||||||
/* shadows */
|
/* shadows */
|
||||||
--drop-shadow-color: rgba(255, 255, 255, 0.08);
|
--drop-shadow-color: rgba(255, 255, 255, 0.08);
|
||||||
--drop-shadow-color-strong: rgba(255, 255, 255, 0.04);
|
--drop-shadow-color-strong: rgba(255, 255, 255, 0.04);
|
||||||
|
@ -2,6 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { useFileSelection } from "../contexts/FileContext";
|
import { useFileSelection } from "../contexts/FileContext";
|
||||||
|
import { useNavigation } from "../contexts/NavigationContext";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
import { createFilesToolStep } from "../components/tools/shared/FilesToolStep";
|
import { createFilesToolStep } from "../components/tools/shared/FilesToolStep";
|
||||||
@ -13,33 +14,40 @@ import { useAutomateOperation } from "../hooks/tools/automate/useAutomateOperati
|
|||||||
import { BaseToolProps } from "../types/tool";
|
import { BaseToolProps } from "../types/tool";
|
||||||
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
|
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
|
||||||
import { useSavedAutomations } from "../hooks/tools/automate/useSavedAutomations";
|
import { useSavedAutomations } from "../hooks/tools/automate/useSavedAutomations";
|
||||||
import { AutomationConfig, AutomationStepData, AutomationMode } from "../types/automation";
|
import { AutomationConfig, AutomationStepData, AutomationMode, AutomationStep } from "../types/automation";
|
||||||
import { AUTOMATION_STEPS } from "../constants/automation";
|
import { AUTOMATION_STEPS } from "../constants/automation";
|
||||||
|
|
||||||
const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedFiles } = useFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
const { setMode } = useNavigation();
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState<'selection' | 'creation' | 'run'>(AUTOMATION_STEPS.SELECTION);
|
const [currentStep, setCurrentStep] = useState<AutomationStep>(AUTOMATION_STEPS.SELECTION);
|
||||||
const [stepData, setStepData] = useState<AutomationStepData>({ step: AUTOMATION_STEPS.SELECTION });
|
const [stepData, setStepData] = useState<AutomationStepData>({ step: AUTOMATION_STEPS.SELECTION });
|
||||||
|
|
||||||
const automateOperation = useAutomateOperation();
|
const automateOperation = useAutomateOperation();
|
||||||
const toolRegistry = useFlatToolRegistry();
|
const toolRegistry = useFlatToolRegistry();
|
||||||
const hasResults = automateOperation.files.length > 0 || automateOperation.downloadUrl !== null;
|
const hasResults = automateOperation.files.length > 0 || automateOperation.downloadUrl !== null;
|
||||||
const { savedAutomations, deleteAutomation, refreshAutomations } = useSavedAutomations();
|
const { savedAutomations, deleteAutomation, refreshAutomations, copyFromSuggested } = useSavedAutomations();
|
||||||
|
|
||||||
const handleStepChange = (data: AutomationStepData) => {
|
const handleStepChange = (data: AutomationStepData) => {
|
||||||
// If navigating away from run step, reset automation results
|
// If navigating away from run step, reset automation results
|
||||||
if (currentStep === AUTOMATION_STEPS.RUN && data.step !== AUTOMATION_STEPS.RUN) {
|
if (currentStep === AUTOMATION_STEPS.RUN && data.step !== AUTOMATION_STEPS.RUN) {
|
||||||
automateOperation.resetResults();
|
automateOperation.resetResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If navigating to selection step, always clear results
|
||||||
|
if (data.step === AUTOMATION_STEPS.SELECTION) {
|
||||||
|
automateOperation.resetResults();
|
||||||
|
automateOperation.clearError();
|
||||||
|
}
|
||||||
|
|
||||||
// If navigating to run step with a different automation, reset results
|
// If navigating to run step with a different automation, reset results
|
||||||
if (data.step === AUTOMATION_STEPS.RUN && data.automation &&
|
if (data.step === AUTOMATION_STEPS.RUN && data.automation &&
|
||||||
stepData.automation && data.automation.id !== stepData.automation.id) {
|
stepData.automation && data.automation.id !== stepData.automation.id) {
|
||||||
automateOperation.resetResults();
|
automateOperation.resetResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
setStepData(data);
|
setStepData(data);
|
||||||
setCurrentStep(data.step);
|
setCurrentStep(data.step);
|
||||||
};
|
};
|
||||||
@ -47,7 +55,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const handleComplete = () => {
|
const handleComplete = () => {
|
||||||
// Reset automation results when completing
|
// Reset automation results when completing
|
||||||
automateOperation.resetResults();
|
automateOperation.resetResults();
|
||||||
|
|
||||||
// Reset to selection step
|
// Reset to selection step
|
||||||
setCurrentStep(AUTOMATION_STEPS.SELECTION);
|
setCurrentStep(AUTOMATION_STEPS.SELECTION);
|
||||||
setStepData({ step: AUTOMATION_STEPS.SELECTION });
|
setStepData({ step: AUTOMATION_STEPS.SELECTION });
|
||||||
@ -56,7 +64,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
|
|
||||||
const renderCurrentStep = () => {
|
const renderCurrentStep = () => {
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 'selection':
|
case AUTOMATION_STEPS.SELECTION:
|
||||||
return (
|
return (
|
||||||
<AutomationSelection
|
<AutomationSelection
|
||||||
savedAutomations={savedAutomations}
|
savedAutomations={savedAutomations}
|
||||||
@ -71,10 +79,18 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
onError?.(`Failed to delete automation: ${automation.name}`);
|
onError?.(`Failed to delete automation: ${automation.name}`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onCopyFromSuggested={async (suggestedAutomation) => {
|
||||||
|
try {
|
||||||
|
await copyFromSuggested(suggestedAutomation);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy suggested automation:', error);
|
||||||
|
onError?.(`Failed to copy automation: ${suggestedAutomation.name}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'creation':
|
case AUTOMATION_STEPS.CREATION:
|
||||||
if (!stepData.mode) {
|
if (!stepData.mode) {
|
||||||
console.error('Creation mode is undefined');
|
console.error('Creation mode is undefined');
|
||||||
return null;
|
return null;
|
||||||
@ -92,7 +108,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'run':
|
case AUTOMATION_STEPS.RUN:
|
||||||
if (!stepData.automation) {
|
if (!stepData.automation) {
|
||||||
console.error('Automation config is undefined');
|
console.error('Automation config is undefined');
|
||||||
return null;
|
return null;
|
||||||
@ -127,7 +143,12 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
createStep(t('automate.selection.title', 'Automation Selection'), {
|
createStep(t('automate.selection.title', 'Automation Selection'), {
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
isCollapsed: currentStep !== AUTOMATION_STEPS.SELECTION,
|
isCollapsed: currentStep !== AUTOMATION_STEPS.SELECTION,
|
||||||
onCollapsedClick: () => setCurrentStep(AUTOMATION_STEPS.SELECTION)
|
onCollapsedClick: () => {
|
||||||
|
// Clear results when clicking back to selection
|
||||||
|
automateOperation.resetResults();
|
||||||
|
setCurrentStep(AUTOMATION_STEPS.SELECTION);
|
||||||
|
setStepData({ step: AUTOMATION_STEPS.SELECTION });
|
||||||
|
}
|
||||||
}, currentStep === AUTOMATION_STEPS.SELECTION ? renderCurrentStep() : null),
|
}, currentStep === AUTOMATION_STEPS.SELECTION ? renderCurrentStep() : null),
|
||||||
|
|
||||||
createStep(stepData.mode === AutomationMode.EDIT
|
createStep(stepData.mode === AutomationMode.EDIT
|
||||||
@ -158,9 +179,13 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
},
|
},
|
||||||
steps: automationSteps,
|
steps: automationSteps,
|
||||||
review: {
|
review: {
|
||||||
isVisible: hasResults,
|
isVisible: hasResults && currentStep === AUTOMATION_STEPS.RUN,
|
||||||
operation: automateOperation,
|
operation: automateOperation,
|
||||||
title: t('automate.reviewTitle', 'Automation Results')
|
title: t('automate.reviewTitle', 'Automation Results'),
|
||||||
|
onFileClick: (file: File) => {
|
||||||
|
onPreviewFile?.(file);
|
||||||
|
setMode('viewer');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -13,15 +13,16 @@ import { useOCRParameters } from "../hooks/tools/ocr/useOCRParameters";
|
|||||||
import { useOCROperation } from "../hooks/tools/ocr/useOCROperation";
|
import { useOCROperation } from "../hooks/tools/ocr/useOCROperation";
|
||||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||||
import { useOCRTips } from "../components/tooltips/useOCRTips";
|
import { useOCRTips } from "../components/tooltips/useOCRTips";
|
||||||
|
import { useAdvancedOCRTips } from "../components/tooltips/useAdvancedOCRTips";
|
||||||
|
|
||||||
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { actions } = useNavigationActions();
|
|
||||||
const { selectedFiles } = useFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
const ocrParams = useOCRParameters();
|
const ocrParams = useOCRParameters();
|
||||||
const ocrOperation = useOCROperation();
|
const ocrOperation = useOCROperation();
|
||||||
const ocrTips = useOCRTips();
|
const ocrTips = useOCRTips();
|
||||||
|
const advancedOCRTips = useAdvancedOCRTips();
|
||||||
|
|
||||||
// Step expansion state management
|
// Step expansion state management
|
||||||
const [expandedStep, setExpandedStep] = useState<"files" | "settings" | "advanced" | null>("files");
|
const [expandedStep, setExpandedStep] = useState<"files" | "settings" | "advanced" | null>("files");
|
||||||
@ -82,7 +83,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
},
|
},
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: t("ocr.settings.title", "Settings"),
|
||||||
isCollapsed: !hasFiles || settingsCollapsed,
|
isCollapsed: !hasFiles || settingsCollapsed,
|
||||||
onCollapsedClick: hasResults
|
onCollapsedClick: hasResults
|
||||||
? handleSettingsReset
|
? handleSettingsReset
|
||||||
@ -108,6 +109,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
if (!hasFiles) return; // Only allow if files are selected
|
if (!hasFiles) return; // Only allow if files are selected
|
||||||
setExpandedStep(expandedStep === "advanced" ? null : "advanced");
|
setExpandedStep(expandedStep === "advanced" ? null : "advanced");
|
||||||
},
|
},
|
||||||
|
tooltip: advancedOCRTips,
|
||||||
content: (
|
content: (
|
||||||
<AdvancedOCRSettings
|
<AdvancedOCRSettings
|
||||||
advancedOptions={ocrParams.parameters.additionalOptions}
|
advancedOptions={ocrParams.parameters.additionalOptions}
|
||||||
|
@ -24,8 +24,10 @@ export interface AutomationTool {
|
|||||||
parameters?: Record<string, any>;
|
parameters?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AutomationStep = typeof import('../constants/automation').AUTOMATION_STEPS[keyof typeof import('../constants/automation').AUTOMATION_STEPS];
|
||||||
|
|
||||||
export interface AutomationStepData {
|
export interface AutomationStepData {
|
||||||
step: 'selection' | 'creation' | 'run';
|
step: AutomationStep;
|
||||||
mode?: AutomationMode;
|
mode?: AutomationMode;
|
||||||
automation?: AutomationConfig;
|
automation?: AutomationConfig;
|
||||||
}
|
}
|
||||||
|
@ -33,7 +33,7 @@ export const isValidMode = (mode: string): mode is ModeType => {
|
|||||||
return validModes.includes(mode as ModeType);
|
return validModes.includes(mode as ModeType);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDefaultMode = (): ModeType => 'pageEditor';
|
export const getDefaultMode = (): ModeType => 'fileEditor';
|
||||||
|
|
||||||
// Route parsing result
|
// Route parsing result
|
||||||
export interface ToolRoute {
|
export interface ToolRoute {
|
||||||
|
26
frontend/src/types/rightRail.ts
Normal file
26
frontend/src/types/rightRail.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export type RightRailSection = 'top' | 'middle' | 'bottom';
|
||||||
|
|
||||||
|
export interface RightRailButtonConfig {
|
||||||
|
/** Unique id for the button, also used to bind action callbacks */
|
||||||
|
id: string;
|
||||||
|
/** Icon element to render */
|
||||||
|
icon: React.ReactNode;
|
||||||
|
/** Tooltip content (can be localized node) */
|
||||||
|
tooltip: React.ReactNode;
|
||||||
|
/** Optional ARIA label for a11y (separate from visual tooltip) */
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** Optional i18n key carried by config */
|
||||||
|
templateKey?: string;
|
||||||
|
/** Visual grouping lane */
|
||||||
|
section?: RightRailSection;
|
||||||
|
/** Sorting within a section (lower first); ties broken by id */
|
||||||
|
order?: number;
|
||||||
|
/** Initial disabled state */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Initial visibility */
|
||||||
|
visible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RightRailAction = () => void;
|
@ -42,7 +42,7 @@
|
|||||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||||
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
"resolveJsonModule": true, /* Enable importing .json files. */
|
||||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user