typescript
This commit is contained in:
@@ -12,5 +12,6 @@ module.exports = {
|
||||
runtime: 'automatic',
|
||||
},
|
||||
],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -2,6 +2,10 @@ import js from '@eslint/js';
|
||||
import pluginReact from 'eslint-plugin-react';
|
||||
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import tsPluginImport from '@typescript-eslint/eslint-plugin';
|
||||
|
||||
const tsPlugin = tsPluginImport.default ?? tsPluginImport;
|
||||
|
||||
const sharedRules = {
|
||||
...js.configs.recommended.rules,
|
||||
@@ -42,6 +46,7 @@ export default [
|
||||
plugins: {
|
||||
react: pluginReact,
|
||||
'react-hooks': pluginReactHooks,
|
||||
'@typescript-eslint': tsPlugin,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
@@ -50,4 +55,32 @@ export default [
|
||||
},
|
||||
rules: sharedRules,
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
...sharedLanguageOptions,
|
||||
parser: tsParser,
|
||||
},
|
||||
plugins: {
|
||||
react: pluginReact,
|
||||
'react-hooks': pluginReactHooks,
|
||||
'@typescript-eslint': tsPlugin,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...sharedRules,
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
Generated
+558
-18
@@ -19,7 +19,10 @@
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.4",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"babel-loader": "^10.0.0",
|
||||
"css-loader": "^7.1.2",
|
||||
"dotenv": "^17.2.3",
|
||||
@@ -29,6 +32,7 @@
|
||||
"globals": "^16.5.0",
|
||||
"html-webpack-plugin": "^5.6.4",
|
||||
"style-loader": "^4.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"webpack": "^5.102.1",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.2"
|
||||
@@ -183,18 +187,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-create-class-features-plugin": {
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz",
|
||||
"integrity": "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz",
|
||||
"integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-annotate-as-pure": "^7.27.3",
|
||||
"@babel/helper-member-expression-to-functions": "^7.27.1",
|
||||
"@babel/helper-member-expression-to-functions": "^7.28.5",
|
||||
"@babel/helper-optimise-call-expression": "^7.27.1",
|
||||
"@babel/helper-replace-supers": "^7.27.1",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
|
||||
"@babel/traverse": "^7.28.3",
|
||||
"@babel/traverse": "^7.28.5",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -295,14 +299,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-member-expression-to-functions": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz",
|
||||
"integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
|
||||
"integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.1"
|
||||
"@babel/traverse": "^7.28.5",
|
||||
"@babel/types": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1541,14 +1545,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-typescript": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz",
|
||||
"integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz",
|
||||
"integrity": "sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-annotate-as-pure": "^7.27.3",
|
||||
"@babel/helper-create-class-features-plugin": "^7.27.1",
|
||||
"@babel/helper-create-class-features-plugin": "^7.28.5",
|
||||
"@babel/helper-plugin-utils": "^7.27.1",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
|
||||
"@babel/plugin-syntax-typescript": "^7.27.1"
|
||||
@@ -1759,9 +1763,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/preset-typescript": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
|
||||
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz",
|
||||
"integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1769,7 +1773,7 @@
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"@babel/plugin-syntax-jsx": "^7.27.1",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
|
||||
"@babel/plugin-transform-typescript": "^7.27.1"
|
||||
"@babel/plugin-transform-typescript": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2303,6 +2307,44 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
"run-parallel": "^1.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.stat": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.walk": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
"fastq": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
|
||||
@@ -2875,6 +2917,377 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz",
|
||||
"integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.4",
|
||||
"@typescript-eslint/type-utils": "8.46.4",
|
||||
"@typescript-eslint/utils": "8.46.4",
|
||||
"@typescript-eslint/visitor-keys": "8.46.4",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.46.4",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz",
|
||||
"integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.4",
|
||||
"@typescript-eslint/types": "8.46.4",
|
||||
"@typescript-eslint/typescript-estree": "8.46.4",
|
||||
"@typescript-eslint/visitor-keys": "8.46.4",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz",
|
||||
"integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.46.4",
|
||||
"@typescript-eslint/types": "^8.46.4",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz",
|
||||
"integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.4",
|
||||
"@typescript-eslint/visitor-keys": "8.46.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz",
|
||||
"integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz",
|
||||
"integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.4",
|
||||
"@typescript-eslint/typescript-estree": "8.46.4",
|
||||
"@typescript-eslint/utils": "8.46.4",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz",
|
||||
"integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz",
|
||||
"integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.46.4",
|
||||
"@typescript-eslint/tsconfig-utils": "8.46.4",
|
||||
"@typescript-eslint/types": "8.46.4",
|
||||
"@typescript-eslint/visitor-keys": "8.46.4",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"minimatch": "^9.0.4",
|
||||
"semver": "^7.6.0",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz",
|
||||
"integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.4",
|
||||
"@typescript-eslint/types": "8.46.4",
|
||||
"@typescript-eslint/typescript-estree": "8.46.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.46.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz",
|
||||
"integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.4",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||
@@ -5413,6 +5826,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
"@nodelib/fs.walk": "^1.2.3",
|
||||
"glob-parent": "^5.1.2",
|
||||
"merge2": "^1.3.0",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@@ -5454,6 +5884,16 @@
|
||||
"node": ">= 4.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/faye-websocket": {
|
||||
"version": "0.11.4",
|
||||
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
|
||||
@@ -5845,6 +6285,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/graphemer": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/handle-thing": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
|
||||
@@ -7151,6 +7598,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
@@ -7954,6 +8411,27 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -8303,6 +8781,17 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/run-applescript": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||
@@ -8316,6 +8805,30 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
@@ -9432,6 +9945,19 @@
|
||||
"tslib": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -9544,6 +10070,20 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "webpack serve --mode development --open",
|
||||
"build": "webpack --mode production",
|
||||
"lint": "eslint src --ext .js,.jsx",
|
||||
"lint": "eslint src --ext .js,.jsx,.ts,.tsx",
|
||||
"test:engine": "node --test tests/workspaceEngine.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -21,7 +21,10 @@
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.4",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"babel-loader": "^10.0.0",
|
||||
"css-loader": "^7.1.2",
|
||||
"dotenv": "^17.2.3",
|
||||
@@ -31,6 +34,7 @@
|
||||
"globals": "^16.5.0",
|
||||
"html-webpack-plugin": "^5.6.4",
|
||||
"style-loader": "^4.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"webpack": "^5.102.1",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.2"
|
||||
|
||||
@@ -7,7 +7,7 @@ import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace';
|
||||
import { useDocumentsPreferences } from './useDocumentsPreferences';
|
||||
import SettingsRoute from './SettingsRoute';
|
||||
|
||||
const AppLayout = () => {
|
||||
const AppLayout: React.FC = () => {
|
||||
const documentsPreferences = useDocumentsPreferences();
|
||||
const {
|
||||
appStatus,
|
||||
@@ -1,9 +1,14 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import Sidebar from '../sidebar/Sidebar';
|
||||
import { useSidebarContext } from '../sidebar/SidebarContext';
|
||||
import { usePanelManager } from './PanelManagerContext';
|
||||
|
||||
const DocumentsLayout = ({ sidebarProps, children }) => {
|
||||
interface DocumentsLayoutProps {
|
||||
sidebarProps?: Record<string, unknown>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const DocumentsLayout: React.FC<DocumentsLayoutProps> = ({ sidebarProps = {}, children }) => {
|
||||
const { collapsed } = useSidebarContext();
|
||||
const { sidebarSuppressed } = usePanelManager();
|
||||
const sidebarHidden = collapsed || sidebarSuppressed;
|
||||
@@ -8,7 +8,9 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
|
||||
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
|
||||
|
||||
const DocumentsRouteContent = () => {
|
||||
type Breadcrumb = { id?: string | number; name?: string; label?: string; title?: string };
|
||||
|
||||
const DocumentsRouteContent: React.FC = () => {
|
||||
const {
|
||||
sidebarProps,
|
||||
documentsTableProps,
|
||||
@@ -64,7 +66,7 @@ const DocumentsRouteContent = () => {
|
||||
navigate(target);
|
||||
}, [navigate, parentBreadcrumb]);
|
||||
|
||||
const handleHeaderBreadcrumbClick = useCallback((crumb) => {
|
||||
const handleHeaderBreadcrumbClick = useCallback((crumb: Breadcrumb) => {
|
||||
if (!crumb || !crumb.id) {
|
||||
return;
|
||||
}
|
||||
@@ -121,7 +123,7 @@ const DocumentsRouteContent = () => {
|
||||
|
||||
const header = surface.header || null;
|
||||
|
||||
let headerTitle = null;
|
||||
let headerTitle: React.ReactNode = null;
|
||||
if (header) {
|
||||
const breadcrumbEntries = Array.isArray(header.breadcrumbs) ? header.breadcrumbs.filter(Boolean) : [];
|
||||
const lastIndex = breadcrumbEntries.length - 1;
|
||||
@@ -176,7 +178,7 @@ const DocumentsRouteContent = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentsRoute = () => (
|
||||
const DocumentsRoute: React.FC = () => (
|
||||
<SidebarProvider>
|
||||
<PanelManagerProvider>
|
||||
<DocumentsRouteContent />
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const DropOverlay = ({ active, folderName }) => (
|
||||
interface DropOverlayProps {
|
||||
active?: boolean;
|
||||
folderName?: string | null;
|
||||
}
|
||||
|
||||
const DropOverlay: React.FC<DropOverlayProps> = ({ active = false, folderName }) => (
|
||||
<div className={`drop-overlay${active ? ' active' : ''}`}>
|
||||
<div className="drop-overlay__content">
|
||||
Drop files to upload to <strong>{folderName || 'this location'}</strong>
|
||||
@@ -11,11 +11,31 @@ import {
|
||||
} from '../utils/webauthn';
|
||||
import { api, useAppDispatch, useAppState } from './appState';
|
||||
|
||||
const LoginRoute = () => {
|
||||
const { status: appStatus, tenantSelection } = useAppState();
|
||||
type StatusVariant = 'info' | 'success' | 'error';
|
||||
|
||||
interface StatusMessage {
|
||||
message: string;
|
||||
variant: StatusVariant;
|
||||
}
|
||||
|
||||
interface TenantOption {
|
||||
id?: string | number | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
interface TenantSelectionState {
|
||||
selectionToken?: string | null;
|
||||
tenants?: TenantOption[];
|
||||
}
|
||||
|
||||
|
||||
const LoginRoute: React.FC = () => {
|
||||
const appState = useAppState();
|
||||
const { status: appStatus } = appState;
|
||||
const tenantSelection = (appState.tenantSelection ?? null) as TenantSelectionState | null;
|
||||
const appDispatch = useAppDispatch();
|
||||
const location = useLocation();
|
||||
const [status, setStatus] = useState(null);
|
||||
const [status, setStatus] = useState<StatusMessage | null>(null);
|
||||
const [selectingTenantId, setSelectingTenantId] = useState(null);
|
||||
const passkeySupported = isWebAuthnAvailable();
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
+144
-57
@@ -1,4 +1,5 @@
|
||||
import React, {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
@@ -7,48 +8,85 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent, RefObject } from 'react';
|
||||
import { useSidebarContext } from '../sidebar/SidebarContext';
|
||||
|
||||
const PanelManagerContext = createContext(null);
|
||||
type PanelKey = 'sidebar' | 'detail';
|
||||
|
||||
const PANEL_LIMITS = {
|
||||
interface PanelLimits {
|
||||
maxRatio: number;
|
||||
minPx: number;
|
||||
}
|
||||
|
||||
interface SetPanelWidthOptions {
|
||||
commit?: boolean;
|
||||
log?: boolean;
|
||||
}
|
||||
|
||||
interface PanelManagerContextValue {
|
||||
sidebarWidth: number;
|
||||
detailWidth: number;
|
||||
sidebarSuppressed: boolean;
|
||||
resizingPanel: PanelKey | null;
|
||||
setPanelWidth: (panel: PanelKey, width: number, options?: SetPanelWidthOptions) => number;
|
||||
startPanelResize: (panel: PanelKey) => void;
|
||||
stopPanelResize: () => void;
|
||||
getPanelWidth: (panel: PanelKey) => number;
|
||||
setDetailActive: (isOpen: boolean) => void;
|
||||
closeDetailPanel: () => void;
|
||||
collapseSidebar: () => void;
|
||||
expandSidebar: () => void;
|
||||
registerDetailCloseHandler: (handler?: (() => void) | null) => void;
|
||||
detailPanelOpen: boolean;
|
||||
}
|
||||
|
||||
type PanelResizeBindings = {
|
||||
panelStyle?: CSSProperties;
|
||||
handleProps: {
|
||||
onPointerDown?: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
isPanelResizing: boolean;
|
||||
};
|
||||
|
||||
const PanelManagerContext = createContext<PanelManagerContextValue | null>(null);
|
||||
|
||||
const PANEL_LIMITS: Record<PanelKey, PanelLimits> = {
|
||||
sidebar: {
|
||||
minRatio: 1 / 6,
|
||||
maxRatio: 1 / 4,
|
||||
minPx: 240,
|
||||
maxRatio: 1 / 3,
|
||||
minPx: 280,
|
||||
},
|
||||
detail: {
|
||||
minRatio: 1 / 4,
|
||||
maxRatio: 3 / 4,
|
||||
maxRatio: 2 / 3,
|
||||
minPx: 320,
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
const STORAGE_KEYS: Record<PanelKey, string> = {
|
||||
sidebar: 'papercrate_sidebar_width',
|
||||
detail: 'papercrate_detail_width',
|
||||
};
|
||||
|
||||
const MINIMAL_FREE_RATIO = 1 / 3;
|
||||
const DEFAULT_SIDEBAR_WIDTH = 320;
|
||||
const DEFAULT_DETAIL_WIDTH = 420;
|
||||
|
||||
const clampPanelWidth = (panel, value) => {
|
||||
const MINIMAL_FREE_RATIO = 1 / 3;
|
||||
const SIDEBAR_SOLO_THRESHOLD = 1 / 2;
|
||||
const MINIMUM_MAIN_CONTENT_WIDTH = 160;
|
||||
|
||||
const clampPanelWidth = (panel: PanelKey, value: number): number => {
|
||||
const numeric = Number(value);
|
||||
const limits = PANEL_LIMITS[panel];
|
||||
if (!limits) {
|
||||
return numeric;
|
||||
}
|
||||
const viewport = window.innerWidth;
|
||||
const ratioMin = Math.round(viewport * limits.minRatio);
|
||||
const minLimit = Math.max(0, limits.minPx);
|
||||
const ratioMax = Math.round(viewport * limits.maxRatio);
|
||||
const minLimit = Math.max(0, Math.max(ratioMin, limits.minPx));
|
||||
const rawMax = Math.max(minLimit, ratioMax);
|
||||
const maxAllowed = Math.min(rawMax, viewport - 160);
|
||||
const targetMax = Math.max(minLimit, maxAllowed);
|
||||
return Math.min(Math.max(numeric, minLimit), targetMax);
|
||||
const rawMax = Math.max(ratioMax, minLimit);
|
||||
const maxAllowed = Math.min(rawMax, viewport - MINIMUM_MAIN_CONTENT_WIDTH);
|
||||
const targetMax = Math.min(viewport, Math.max(minLimit, maxAllowed));
|
||||
return Math.min(Math.max(numeric, minLimit), Math.max(0, targetMax));
|
||||
};
|
||||
|
||||
const readStoredWidth = (panel, fallback) => {
|
||||
const raw = window?.localStorage?.getItem(STORAGE_KEYS[panel]);
|
||||
const readStoredWidth = (panel: PanelKey, fallback: number): number => {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEYS[panel]);
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
@@ -56,22 +94,26 @@ const readStoredWidth = (panel, fallback) => {
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
|
||||
const persistWidth = (panel, value) => {
|
||||
window?.localStorage?.setItem(STORAGE_KEYS[panel], String(Math.round(value)));
|
||||
const persistWidth = (panel: PanelKey, value: number): void => {
|
||||
window.localStorage.setItem(STORAGE_KEYS[panel], String(Math.round(value)));
|
||||
};
|
||||
|
||||
const applyPanelWidthToRoot = (panel, width) => {
|
||||
const applyPanelWidthToRoot = (panel: PanelKey, width: number): void => {
|
||||
if (!Number.isFinite(width)) {
|
||||
return;
|
||||
}
|
||||
const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width';
|
||||
document?.documentElement?.style.setProperty(varName, `${width}px`);
|
||||
document.documentElement.style.setProperty(varName, `${width}px`);
|
||||
};
|
||||
|
||||
export const PanelManagerProvider = ({ children }) => {
|
||||
interface PanelManagerProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const PanelManagerProvider: React.FC<PanelManagerProviderProps> = ({ children }) => {
|
||||
const { collapsed, setCollapsed } = useSidebarContext();
|
||||
const initialSidebarWidth = readStoredWidth('sidebar', 320);
|
||||
const initialDetailWidth = readStoredWidth('detail', 420);
|
||||
const initialSidebarWidth = readStoredWidth('sidebar', DEFAULT_SIDEBAR_WIDTH);
|
||||
const initialDetailWidth = readStoredWidth('detail', DEFAULT_DETAIL_WIDTH);
|
||||
|
||||
const [sidebarWidth, setSidebarWidthState] = useState(() => clampPanelWidth('sidebar', initialSidebarWidth));
|
||||
const [detailWidth, setDetailWidthState] = useState(() => clampPanelWidth('detail', initialDetailWidth));
|
||||
@@ -81,6 +123,7 @@ export const PanelManagerProvider = ({ children }) => {
|
||||
|
||||
const detailCloseHandlerRef = useRef(null);
|
||||
const panelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
|
||||
const preferredPanelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
|
||||
|
||||
const closeDetailPanel = useCallback(() => {
|
||||
const handler = detailCloseHandlerRef.current;
|
||||
@@ -97,15 +140,12 @@ export const PanelManagerProvider = ({ children }) => {
|
||||
applyPanelWidthToRoot('detail', detailWidth);
|
||||
}, [detailWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
persistWidth('sidebar', sidebarWidth);
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
persistWidth('detail', detailWidth);
|
||||
}, [detailWidth]);
|
||||
|
||||
const logPanelState = useCallback((panel, action, value) => {
|
||||
const handlePanelLayoutChange = useCallback((
|
||||
panel: PanelKey,
|
||||
action: 'opened' | 'closed' | 'resized',
|
||||
value?: number,
|
||||
{ detailOpen }: { detailOpen?: boolean } = {},
|
||||
) => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const sidebarWidth = panelWidthsRef.current.sidebar;
|
||||
const detailWidth = panelWidthsRef.current.detail;
|
||||
@@ -113,6 +153,7 @@ export const PanelManagerProvider = ({ children }) => {
|
||||
const freeRatio = viewportWidth > 0 ? freeSpace / viewportWidth : 0;
|
||||
|
||||
const meetsThreshold = freeRatio >= MINIMAL_FREE_RATIO;
|
||||
const effectiveDetailOpen = detailOpen ?? detailPanelOpen;
|
||||
|
||||
if (panel === 'detail' && !collapsed) {
|
||||
if (action === 'opened' || action === 'resized') {
|
||||
@@ -122,7 +163,7 @@ export const PanelManagerProvider = ({ children }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && detailPanelOpen) {
|
||||
if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && effectiveDetailOpen) {
|
||||
closeDetailPanel();
|
||||
}
|
||||
|
||||
@@ -138,18 +179,17 @@ export const PanelManagerProvider = ({ children }) => {
|
||||
minimalFreeRatio: MINIMAL_FREE_RATIO,
|
||||
meetsThreshold,
|
||||
});
|
||||
}, [collapsed, sidebarSuppressed, closeDetailPanel, detailPanelOpen]);
|
||||
}, [collapsed, closeDetailPanel, detailPanelOpen]);
|
||||
|
||||
const collapseSidebar = useCallback(() => {
|
||||
if (!collapsed) {
|
||||
setCollapsed(true);
|
||||
setSidebarSuppressed(false);
|
||||
logPanelState('sidebar', 'closed');
|
||||
handlePanelLayoutChange('sidebar', 'closed');
|
||||
}
|
||||
}, [collapsed, setCollapsed, logPanelState]);
|
||||
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
|
||||
|
||||
const setPanelWidth = useCallback(
|
||||
(panel, width, commit = true) => {
|
||||
(panel: PanelKey, width: number, { commit = true, log = true }: SetPanelWidthOptions = {}) => {
|
||||
const clamped = clampPanelWidth(panel, width);
|
||||
if (!Number.isFinite(clamped)) {
|
||||
return panelWidthsRef.current[panel];
|
||||
@@ -162,34 +202,78 @@ export const PanelManagerProvider = ({ children }) => {
|
||||
panelWidthsRef.current[panel] = clamped;
|
||||
applyPanelWidthToRoot(panel, clamped);
|
||||
if (commit) {
|
||||
preferredPanelWidthsRef.current[panel] = clamped;
|
||||
persistWidth(panel, clamped);
|
||||
}
|
||||
|
||||
logPanelState(panel, 'resized', clamped);
|
||||
if (log) {
|
||||
handlePanelLayoutChange(panel, 'resized', clamped);
|
||||
}
|
||||
return clamped;
|
||||
},
|
||||
[collapsed, logPanelState, sidebarSuppressed],
|
||||
[handlePanelLayoutChange],
|
||||
);
|
||||
|
||||
const registerDetailCloseHandler = useCallback((handler = null) => {
|
||||
detailCloseHandlerRef.current = typeof handler === 'function' ? handler : null;
|
||||
const resetSidebarPreferredWidth = useCallback(() => {
|
||||
if (preferredPanelWidthsRef.current.sidebar === DEFAULT_SIDEBAR_WIDTH) {
|
||||
return;
|
||||
}
|
||||
preferredPanelWidthsRef.current.sidebar = DEFAULT_SIDEBAR_WIDTH;
|
||||
persistWidth('sidebar', DEFAULT_SIDEBAR_WIDTH);
|
||||
}, []);
|
||||
|
||||
const clampPanelsWithinViewport = useCallback(() => {
|
||||
const viewportWidth = Math.max(0, Number(window.innerWidth) || 0);
|
||||
if (viewportWidth === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const desiredSidebarWidth = preferredPanelWidthsRef.current.sidebar;
|
||||
const desiredDetailWidth = preferredPanelWidthsRef.current.detail;
|
||||
|
||||
let sidebarDisplayWidth = Math.min(desiredSidebarWidth, viewportWidth);
|
||||
let remainingWidth = Math.max(0, viewportWidth - sidebarDisplayWidth);
|
||||
let detailDisplayWidth = Math.min(desiredDetailWidth, remainingWidth);
|
||||
if (detailPanelOpen && sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) {
|
||||
sidebarDisplayWidth = 0;
|
||||
detailDisplayWidth = Math.min(desiredDetailWidth || viewportWidth, viewportWidth);
|
||||
} else if (sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) {
|
||||
sidebarDisplayWidth = viewportWidth;
|
||||
detailDisplayWidth = 0;
|
||||
resetSidebarPreferredWidth();
|
||||
}
|
||||
|
||||
panelWidthsRef.current.sidebar = sidebarDisplayWidth;
|
||||
panelWidthsRef.current.detail = detailDisplayWidth;
|
||||
|
||||
setSidebarWidthState((prev) => (prev === sidebarDisplayWidth ? prev : sidebarDisplayWidth));
|
||||
setDetailWidthState((prev) => (prev === detailDisplayWidth ? prev : detailDisplayWidth));
|
||||
}, [detailPanelOpen, resetSidebarPreferredWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
clampPanelsWithinViewport();
|
||||
window.addEventListener('resize', clampPanelsWithinViewport);
|
||||
return () => window.removeEventListener('resize', clampPanelsWithinViewport);
|
||||
}, [clampPanelsWithinViewport]);
|
||||
|
||||
const registerDetailCloseHandler = useCallback((handler: (() => void) | null = null) => {
|
||||
detailCloseHandlerRef.current = handler ?? null;
|
||||
}, []);
|
||||
|
||||
const setDetailActive = useCallback(
|
||||
(isOpen) => {
|
||||
setDetailPanelOpen(Boolean(isOpen));
|
||||
logPanelState('detail', isOpen ? 'opened' : 'closed');
|
||||
handlePanelLayoutChange('detail', isOpen ? 'opened' : 'closed');
|
||||
},
|
||||
[logPanelState],
|
||||
[handlePanelLayoutChange],
|
||||
);
|
||||
|
||||
const expandSidebar = useCallback(() => {
|
||||
if (collapsed) {
|
||||
setCollapsed(false);
|
||||
}
|
||||
setSidebarSuppressed(false);
|
||||
logPanelState('sidebar', 'opened');
|
||||
}, [collapsed, setCollapsed, logPanelState]);
|
||||
handlePanelLayoutChange('sidebar', 'opened');
|
||||
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
|
||||
|
||||
const startPanelResize = useCallback((panel) => {
|
||||
setResizingPanel(panel);
|
||||
@@ -247,7 +331,10 @@ export const usePanelManager = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true } = {}) => {
|
||||
export const usePanelResizeBindings = (
|
||||
panel: PanelKey,
|
||||
{ panelRef = null, enabled = true }: { panelRef?: RefObject<HTMLElement> | null; enabled?: boolean } = {},
|
||||
): PanelResizeBindings => {
|
||||
const {
|
||||
sidebarWidth,
|
||||
detailWidth,
|
||||
@@ -277,7 +364,7 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true
|
||||
useEffect(() => () => teardownListeners(), [teardownListeners]);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event) => {
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!enabled || !panelRef?.current) {
|
||||
return;
|
||||
}
|
||||
@@ -294,18 +381,18 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true
|
||||
event.currentTarget?.setPointerCapture?.(pointerId);
|
||||
let lastWidth = startWidth;
|
||||
|
||||
const handlePointerMove = (moveEvent) => {
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
if (moveEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
const delta = panel === 'sidebar'
|
||||
? moveEvent.clientX - startX
|
||||
: startX - moveEvent.clientX;
|
||||
lastWidth = setPanelWidth(panel, startWidth + delta, false);
|
||||
lastWidth = setPanelWidth(panel, startWidth + delta, { commit: false });
|
||||
latestWidthRef.current = lastWidth;
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent) => {
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
if (upEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
@@ -6,7 +6,12 @@ import useCapabilitySets from '../settings/useCapabilitySets';
|
||||
import useCapabilities from '../settings/useCapabilities';
|
||||
import { api } from './appState';
|
||||
|
||||
const SettingsRoute = ({ open = true, onClose }) => {
|
||||
interface SettingsRouteProps {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const SettingsRoute: React.FC<SettingsRouteProps> = ({ open = true, onClose }) => {
|
||||
const {
|
||||
token,
|
||||
notifyApiError,
|
||||
@@ -19,7 +24,7 @@ const SettingsRoute = ({ open = true, onClose }) => {
|
||||
refreshPasskeys,
|
||||
registerPasskey,
|
||||
revokePasskey,
|
||||
} = useAppShell();
|
||||
} = useAppShell() as Record<string, any>;
|
||||
|
||||
const {
|
||||
tokens,
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
CloseIcon,
|
||||
@@ -12,7 +13,23 @@ import {
|
||||
} from '../ui/icons';
|
||||
import PanelHeader from '../ui/PanelHeader';
|
||||
|
||||
const STATUS_META = {
|
||||
type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error' | (string & {});
|
||||
|
||||
interface UploadQueueItem {
|
||||
id: string | number;
|
||||
name: string;
|
||||
status: UploadStatus;
|
||||
error?: string | null;
|
||||
document?: { id?: string | number; title?: string };
|
||||
conflictDocumentId?: string | number;
|
||||
}
|
||||
|
||||
interface UploadQueueOverlayProps {
|
||||
queue?: UploadQueueItem[];
|
||||
onClearQueue?: () => void;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<string, { label: string; tone: string; icon: JSX.Element }> = {
|
||||
pending: {
|
||||
label: 'Queued',
|
||||
tone: 'muted',
|
||||
@@ -40,7 +57,7 @@ const STATUS_META = {
|
||||
},
|
||||
};
|
||||
|
||||
const UploadQueueOverlay = ({ queue = [], onClearQueue }) => {
|
||||
const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProps): JSX.Element | null => {
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
@@ -1,10 +1,52 @@
|
||||
import React, { useContext, useEffect, useMemo, useReducer } from 'react';
|
||||
import api from '../lib/api';
|
||||
|
||||
const storage = window.sessionStorage;
|
||||
type Tenant = Record<string, unknown> | null;
|
||||
|
||||
interface TenantSelection {
|
||||
selectionToken: string;
|
||||
tenants: Tenant[];
|
||||
}
|
||||
|
||||
type AppStatus =
|
||||
| 'logged-out'
|
||||
| 'authenticating'
|
||||
| 'authenticated'
|
||||
| 'selecting-tenant'
|
||||
| 'bootstrapping'
|
||||
| 'ready';
|
||||
|
||||
interface AppState {
|
||||
status: AppStatus;
|
||||
token: string;
|
||||
error: string | null;
|
||||
isRefreshing: boolean;
|
||||
tenantSelection: TenantSelection | null;
|
||||
tenant: Tenant;
|
||||
tenants: Tenant[];
|
||||
}
|
||||
|
||||
type AppAction =
|
||||
| { type: 'LOGIN_REQUEST' }
|
||||
| { type: 'LOGIN_SUCCESS'; token: string; tenant?: Tenant }
|
||||
| { type: 'LOGIN_FAILURE'; error?: string | null }
|
||||
| { type: 'TENANT_SELECTION_REQUIRED'; selectionToken: string; tenants: Tenant[] }
|
||||
| { type: 'CLEAR_TENANT_SELECTION' }
|
||||
| { type: 'LOGOUT_SUCCESS' }
|
||||
| { type: 'BOOTSTRAP_START' }
|
||||
| { type: 'BOOTSTRAP_SUCCESS' }
|
||||
| { type: 'BOOTSTRAP_FAILURE'; error?: string | null }
|
||||
| { type: 'TOKEN_REFRESH_START' }
|
||||
| { type: 'TOKEN_REFRESH_SUCCESS'; token: string; tenant?: Tenant }
|
||||
| { type: 'TOKEN_REFRESH_FAILURE'; error?: string | null }
|
||||
| { type: 'LOGOUT' }
|
||||
| { type: 'RESET_ERROR' }
|
||||
| { type: 'SET_TENANTS'; tenants: Tenant[] };
|
||||
|
||||
const storage = typeof window !== 'undefined' ? window.sessionStorage : null;
|
||||
|
||||
const STORED_TOKEN = storage?.getItem('papercrate_token') ?? '';
|
||||
let STORED_TENANT = null;
|
||||
let STORED_TENANT: Tenant = null;
|
||||
|
||||
if (storage) {
|
||||
try {
|
||||
@@ -21,7 +63,7 @@ if (STORED_TOKEN) {
|
||||
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
|
||||
}
|
||||
|
||||
const initialAppState = {
|
||||
const initialAppState: AppState = {
|
||||
status: STORED_TOKEN ? 'authenticated' : 'logged-out',
|
||||
token: STORED_TOKEN,
|
||||
error: null,
|
||||
@@ -31,10 +73,10 @@ const initialAppState = {
|
||||
tenants: [],
|
||||
};
|
||||
|
||||
const AppStateContext = React.createContext(null);
|
||||
const AppDispatchContext = React.createContext(null);
|
||||
const AppStateContext = React.createContext<AppState | null>(null);
|
||||
const AppDispatchContext = React.createContext<React.Dispatch<AppAction> | null>(null);
|
||||
|
||||
const appStateReducer = (state, action) => {
|
||||
const appStateReducer = (state: AppState, action: AppAction): AppState => {
|
||||
switch (action.type) {
|
||||
case 'LOGIN_REQUEST':
|
||||
return {
|
||||
@@ -52,14 +94,14 @@ const appStateReducer = (state, action) => {
|
||||
token: action.token,
|
||||
error: null,
|
||||
tenantSelection: null,
|
||||
tenant: action.tenant || null,
|
||||
tenant: action.tenant ?? null,
|
||||
tenants: state.tenants,
|
||||
};
|
||||
case 'LOGIN_FAILURE':
|
||||
return {
|
||||
status: 'logged-out',
|
||||
token: '',
|
||||
error: action.error || null,
|
||||
error: action.error ?? null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
@@ -89,6 +131,7 @@ const appStateReducer = (state, action) => {
|
||||
tenants: [],
|
||||
};
|
||||
case 'LOGOUT_SUCCESS':
|
||||
case 'LOGOUT':
|
||||
return {
|
||||
status: 'logged-out',
|
||||
token: '',
|
||||
@@ -103,7 +146,7 @@ const appStateReducer = (state, action) => {
|
||||
case 'BOOTSTRAP_SUCCESS':
|
||||
return { ...state, status: 'ready', error: null };
|
||||
case 'BOOTSTRAP_FAILURE':
|
||||
return { ...state, status: 'authenticated', error: action.error || null };
|
||||
return { ...state, status: 'authenticated', error: action.error ?? null };
|
||||
case 'TOKEN_REFRESH_START':
|
||||
return { ...state, isRefreshing: true, error: null };
|
||||
case 'TOKEN_REFRESH_SUCCESS':
|
||||
@@ -113,24 +156,14 @@ const appStateReducer = (state, action) => {
|
||||
isRefreshing: false,
|
||||
status: state.status === 'logged-out' ? 'authenticated' : state.status,
|
||||
tenantSelection: null,
|
||||
tenant: action.tenant || state.tenant || null,
|
||||
tenant: action.tenant ?? state.tenant ?? null,
|
||||
tenants: state.tenants,
|
||||
};
|
||||
case 'TOKEN_REFRESH_FAILURE':
|
||||
return {
|
||||
status: 'logged-out',
|
||||
token: '',
|
||||
error: action.error || null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
tenants: [],
|
||||
};
|
||||
case 'LOGOUT':
|
||||
return {
|
||||
status: 'logged-out',
|
||||
token: '',
|
||||
error: null,
|
||||
error: action.error ?? null,
|
||||
isRefreshing: false,
|
||||
tenantSelection: null,
|
||||
tenant: null,
|
||||
@@ -148,7 +181,7 @@ const appStateReducer = (state, action) => {
|
||||
}
|
||||
};
|
||||
|
||||
const AppStateProvider = ({ children }) => {
|
||||
const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(appStateReducer, initialAppState);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -216,7 +249,7 @@ const AppStateProvider = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const useAppState = () => {
|
||||
const useAppState = (): AppState => {
|
||||
const context = useContext(AppStateContext);
|
||||
if (!context) {
|
||||
throw new Error('useAppState must be used within an AppStateProvider.');
|
||||
@@ -224,7 +257,7 @@ const useAppState = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
const useAppDispatch = () => {
|
||||
const useAppDispatch = (): React.Dispatch<AppAction> => {
|
||||
const context = useContext(AppDispatchContext);
|
||||
if (!context) {
|
||||
throw new Error('useAppDispatch must be used within an AppStateProvider.');
|
||||
@@ -1,13 +1,30 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface DetailDocument {
|
||||
id?: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseDetailPanelOptions {
|
||||
documentLookup: Map<string | number, DetailDocument>;
|
||||
orderedSelectedDocuments: DetailDocument[];
|
||||
}
|
||||
|
||||
interface OpenDetailPanelArgs {
|
||||
documentId?: string | number;
|
||||
document?: DetailDocument | null;
|
||||
documentIds?: Array<string | number>;
|
||||
documents?: DetailDocument[];
|
||||
}
|
||||
|
||||
export const useDetailPanel = ({
|
||||
documentLookup,
|
||||
orderedSelectedDocuments,
|
||||
}) => {
|
||||
}: UseDetailPanelOptions) => {
|
||||
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
|
||||
const [detailPanelDocId, setDetailPanelDocId] = useState(null);
|
||||
const [detailPanelDocument, setDetailPanelDocument] = useState(null);
|
||||
const latestOrderedDocsRef = useRef([]);
|
||||
const [detailPanelDocId, setDetailPanelDocId] = useState<string | number | null>(null);
|
||||
const [detailPanelDocument, setDetailPanelDocument] = useState<DetailDocument | null>(null);
|
||||
const latestOrderedDocsRef = useRef<DetailDocument[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
latestOrderedDocsRef.current = orderedSelectedDocuments;
|
||||
@@ -34,7 +51,7 @@ export const useDetailPanel = ({
|
||||
}, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]);
|
||||
|
||||
const openDetailPanel = useCallback(
|
||||
({ documentId, document, documentIds, documents } = {}) => {
|
||||
({ documentId, document, documentIds, documents }: OpenDetailPanelArgs = {}) => {
|
||||
let targetDoc = document || null;
|
||||
let targetId = documentId ?? document?.id ?? null;
|
||||
|
||||
@@ -66,7 +83,7 @@ export const useDetailPanel = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setDetailPanelDocId(targetDoc?.id || targetId || null);
|
||||
setDetailPanelDocId(targetDoc?.id ?? targetId ?? null);
|
||||
setDetailPanelDocument(targetDoc || null);
|
||||
setDetailPanelOpen(Boolean(targetDoc || targetId));
|
||||
},
|
||||
@@ -1,4 +1,72 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
type DocumentId = string | number;
|
||||
type FolderId = DocumentId | 'root';
|
||||
|
||||
type DocumentLike = {
|
||||
id?: DocumentId;
|
||||
folder_id?: FolderId | null;
|
||||
filename?: string | null;
|
||||
current_version?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type PreviewEntry = {
|
||||
url?: string;
|
||||
contentType?: string | null;
|
||||
filename?: string | null;
|
||||
expiresAt?: number;
|
||||
canGoPrev?: boolean;
|
||||
canGoNext?: boolean;
|
||||
goPrev?: () => void;
|
||||
goNext?: () => void;
|
||||
} | null;
|
||||
|
||||
interface AssetManagerLike {
|
||||
hydrateDetail: (payload: unknown) => { document?: DocumentLike } | null | undefined;
|
||||
hydrateDocument: (payload: unknown) => DocumentLike | null | undefined;
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
get: <T = unknown>(path: string) => Promise<{ data: T }>;
|
||||
}
|
||||
|
||||
type NavigateHandler = (path: string, options?: { replace?: boolean }) => void;
|
||||
|
||||
interface UseDocumentPreviewArgs {
|
||||
routeDocumentId?: DocumentId | null;
|
||||
documents: DocumentLike[];
|
||||
searchResults: DocumentLike[] | null;
|
||||
selectedFolder?: FolderId | null;
|
||||
assetManager: AssetManagerLike;
|
||||
api: ApiClient;
|
||||
resolveApiPath?: (path: string) => string;
|
||||
notifyApiError: (error: unknown, message: string) => void;
|
||||
navigate: NavigateHandler;
|
||||
locationPathname: string;
|
||||
locationSearch: string;
|
||||
detailPanelControlRef: MutableRefObject<{
|
||||
open?: (args?: { documentIds?: DocumentId[] }) => void;
|
||||
close?: () => void;
|
||||
} | null>;
|
||||
setActivePreviewId: Dispatch<SetStateAction<DocumentId | null>>;
|
||||
}
|
||||
|
||||
interface UseDocumentPreviewResult {
|
||||
previewEntries: Map<DocumentId, PreviewEntry>;
|
||||
previewDocuments: Map<DocumentId, DocumentLike>;
|
||||
ensurePreviewUrl: (documentId: DocumentId | null, options?: { force?: boolean }) => Promise<PreviewEntry | null>;
|
||||
ensurePreviewData: (documentId: DocumentId | null) => Promise<DocumentLike | null>;
|
||||
openDocumentPreview: (documentId: DocumentId | null, options?: { replace?: boolean }) => void;
|
||||
closeDocumentPreview: (folderId?: FolderId | null) => void;
|
||||
resetPreviewState: () => void;
|
||||
removePreviewEntries: (ids: DocumentId[]) => void;
|
||||
}
|
||||
|
||||
const useDocumentPreview = ({
|
||||
routeDocumentId,
|
||||
@@ -14,11 +82,11 @@ const useDocumentPreview = ({
|
||||
locationSearch,
|
||||
detailPanelControlRef,
|
||||
setActivePreviewId,
|
||||
}) => {
|
||||
const [previewEntries, setPreviewEntries] = useState(() => new Map());
|
||||
const [previewDocuments, setPreviewDocuments] = useState(() => new Map());
|
||||
const previewInflightRef = useRef(new Map());
|
||||
const previewReturnPathRef = useRef(null);
|
||||
}: UseDocumentPreviewArgs): UseDocumentPreviewResult => {
|
||||
const [previewEntries, setPreviewEntries] = useState<Map<DocumentId, PreviewEntry>>(() => new Map());
|
||||
const [previewDocuments, setPreviewDocuments] = useState<Map<DocumentId, DocumentLike>>(() => new Map());
|
||||
const previewInflightRef = useRef<Map<DocumentId, Promise<PreviewEntry | null>>>(new Map());
|
||||
const previewReturnPathRef = useRef<string | null>(null);
|
||||
|
||||
const resetPreviewState = useCallback(() => {
|
||||
setPreviewEntries(() => new Map());
|
||||
@@ -26,7 +94,7 @@ const useDocumentPreview = ({
|
||||
previewReturnPathRef.current = null;
|
||||
}, []);
|
||||
|
||||
const removePreviewEntries = useCallback((ids) => {
|
||||
const removePreviewEntries = useCallback((ids: DocumentId[]) => {
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -46,7 +114,7 @@ const useDocumentPreview = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const cachePreviewDocument = useCallback((doc) => {
|
||||
const cachePreviewDocument = useCallback((doc: DocumentLike | null | undefined) => {
|
||||
if (!doc?.id) {
|
||||
return;
|
||||
}
|
||||
@@ -61,7 +129,7 @@ const useDocumentPreview = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeCachedPreviewDocument = useCallback((documentId) => {
|
||||
const removeCachedPreviewDocument = useCallback((documentId?: DocumentId | null) => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
@@ -76,7 +144,7 @@ const useDocumentPreview = ({
|
||||
}, []);
|
||||
|
||||
const ensurePreviewUrl = useCallback(
|
||||
async (documentId, { force = false } = {}) => {
|
||||
async (documentId: DocumentId | null, { force = false }: { force?: boolean } = {}): Promise<PreviewEntry | null> => {
|
||||
if (!documentId) return null;
|
||||
|
||||
const existing = previewEntries.get(documentId) || null;
|
||||
@@ -87,19 +155,19 @@ const useDocumentPreview = ({
|
||||
}
|
||||
|
||||
if (!force && previewInflightRef.current.has(documentId)) {
|
||||
return previewInflightRef.current.get(documentId);
|
||||
return previewInflightRef.current.get(documentId) || null;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
const request: Promise<PreviewEntry | null> = (async () => {
|
||||
try {
|
||||
const docResponse = await api.get(`/documents/${documentId}`);
|
||||
const docResponse = await api.get<{ document?: Record<string, any> }>(`/documents/${documentId}`);
|
||||
const downloadPath = docResponse.data?.document?.current_version?.download_path;
|
||||
if (!downloadPath || !resolveApiPath) {
|
||||
throw new Error('Document missing download path');
|
||||
}
|
||||
|
||||
const href = resolveApiPath(downloadPath);
|
||||
const entry = {
|
||||
const entry: PreviewEntry = {
|
||||
url: href,
|
||||
contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
|
||||
filename: docResponse.data?.document?.filename,
|
||||
@@ -122,11 +190,11 @@ const useDocumentPreview = ({
|
||||
previewInflightRef.current.set(documentId, request);
|
||||
return request;
|
||||
},
|
||||
[previewEntries, api, resolveApiPath, notifyApiError, setPreviewEntries],
|
||||
[previewEntries, api, resolveApiPath, notifyApiError],
|
||||
);
|
||||
|
||||
const ensurePreviewData = useCallback(
|
||||
async (documentId) => {
|
||||
async (documentId: DocumentId | null): Promise<DocumentLike | null> => {
|
||||
if (!documentId) return null;
|
||||
|
||||
const findInCache = () => {
|
||||
@@ -139,18 +207,18 @@ const useDocumentPreview = ({
|
||||
if (!doc) {
|
||||
const { data } = await api.get(`/documents/${documentId}`);
|
||||
const hydratedDetail = assetManager.hydrateDetail(data);
|
||||
const fetched = hydratedDetail?.document || data.document || data;
|
||||
doc = fetched ? assetManager.hydrateDocument(fetched) : null;
|
||||
const fetched = hydratedDetail?.document || (data as { document?: DocumentLike })?.document || data;
|
||||
doc = fetched ? assetManager.hydrateDocument(fetched) || null : null;
|
||||
if (!doc) {
|
||||
throw new Error('Document metadata unavailable.');
|
||||
}
|
||||
|
||||
const existsInDocuments = documents.some((item) => item.id === doc.id);
|
||||
const existsInDocuments = documents.some((item) => item.id === doc?.id);
|
||||
const existsInSearch = Array.isArray(searchResults)
|
||||
? searchResults.some((item) => item.id === doc.id)
|
||||
? searchResults.some((item) => item.id === doc?.id)
|
||||
: false;
|
||||
|
||||
if (existsInDocuments || existsInSearch) {
|
||||
if (doc?.id && (existsInDocuments || existsInSearch)) {
|
||||
removeCachedPreviewDocument(doc.id);
|
||||
} else {
|
||||
cachePreviewDocument(doc);
|
||||
@@ -180,9 +248,9 @@ const useDocumentPreview = ({
|
||||
);
|
||||
|
||||
const openDocumentPreview = useCallback(
|
||||
(documentId, { replace = false } = {}) => {
|
||||
(documentId: DocumentId | null, { replace = false }: { replace?: boolean } = {}) => {
|
||||
if (!documentId) return;
|
||||
detailPanelControlRef.current.close();
|
||||
detailPanelControlRef.current?.close?.();
|
||||
previewReturnPathRef.current = `${locationPathname}${locationSearch}`;
|
||||
navigate(`/documents/${documentId}`, { replace });
|
||||
},
|
||||
@@ -190,7 +258,7 @@ const useDocumentPreview = ({
|
||||
);
|
||||
|
||||
const closeDocumentPreview = useCallback(
|
||||
(folderId = null) => {
|
||||
(folderId: FolderId | null = null) => {
|
||||
const fallbackPath = previewReturnPathRef.current;
|
||||
previewReturnPathRef.current = null;
|
||||
|
||||
@@ -211,7 +279,7 @@ const useDocumentPreview = ({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDocumentPreview();
|
||||
}
|
||||
@@ -248,7 +316,7 @@ const useDocumentPreview = ({
|
||||
}
|
||||
const next = new Map(prev);
|
||||
let changed = false;
|
||||
const prune = (list) => {
|
||||
const prune = (list?: DocumentLike[] | null) => {
|
||||
if (!Array.isArray(list)) {
|
||||
return;
|
||||
}
|
||||
+53
-23
@@ -1,6 +1,30 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
const DEFAULT_INITIAL_ENTRIES = [];
|
||||
type RowKey = string;
|
||||
type DocumentId = string | number;
|
||||
|
||||
interface SelectionEventLike {
|
||||
shiftKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
preventDefault?: () => void;
|
||||
}
|
||||
|
||||
interface UseDocumentSelectionOptions {
|
||||
resolveDocumentRowKey: (id: DocumentId | null | undefined) => RowKey | null | undefined;
|
||||
resolveFolderRowKey: (id: DocumentId | null | undefined) => RowKey | null | undefined;
|
||||
isDocumentRowKey: (key?: RowKey | null) => boolean;
|
||||
isFolderRowKey: (key?: RowKey | null) => boolean;
|
||||
getRowId: (key?: RowKey | null) => DocumentId | null | undefined;
|
||||
initialEntries?: RowKey[];
|
||||
}
|
||||
|
||||
interface ApplySelectionOptions {
|
||||
anchor?: RowKey | null;
|
||||
interactedKeys?: RowKey[];
|
||||
}
|
||||
|
||||
const DEFAULT_INITIAL_ENTRIES: RowKey[] = [];
|
||||
|
||||
export const useDocumentSelection = ({
|
||||
resolveDocumentRowKey,
|
||||
@@ -9,21 +33,24 @@ export const useDocumentSelection = ({
|
||||
isFolderRowKey,
|
||||
getRowId,
|
||||
initialEntries = DEFAULT_INITIAL_ENTRIES,
|
||||
}) => {
|
||||
const [selectedEntries, setSelectedEntries] = useState(initialEntries);
|
||||
const [selectionOrder, setSelectionOrder] = useState(initialEntries);
|
||||
const selectionOrderRef = useRef(initialEntries);
|
||||
const selectionAnchorRef = useRef(null);
|
||||
}: UseDocumentSelectionOptions) => {
|
||||
const [selectedEntries, setSelectedEntries] = useState<RowKey[]>(initialEntries);
|
||||
const [selectionOrder, setSelectionOrder] = useState<RowKey[]>(initialEntries);
|
||||
const selectionOrderRef = useRef<RowKey[]>(initialEntries);
|
||||
const selectionAnchorRef = useRef<RowKey | null>(null);
|
||||
const selectionInitializedRef = useRef(false);
|
||||
const [focusedDocumentId, setFocusedDocumentId] = useState(null);
|
||||
const [focusedRowKey, setFocusedRowKey] = useState(null);
|
||||
const [focusedDocumentId, setFocusedDocumentId] = useState<DocumentId | null>(null);
|
||||
const [focusedRowKey, setFocusedRowKey] = useState<RowKey | null>(null);
|
||||
|
||||
const visibleRowKeySetRef = useRef(new Set());
|
||||
const navigableRowKeysRef = useRef([]);
|
||||
const visibleRowKeySetRef = useRef<Set<RowKey>>(new Set());
|
||||
const navigableRowKeysRef = useRef<RowKey[]>([]);
|
||||
|
||||
const configureSelectionEnvironment = useCallback(({
|
||||
visibleRowKeySet,
|
||||
navigableRowKeys,
|
||||
}: {
|
||||
visibleRowKeySet?: Set<RowKey>;
|
||||
navigableRowKeys?: RowKey[];
|
||||
}) => {
|
||||
if (visibleRowKeySet) {
|
||||
visibleRowKeySetRef.current = visibleRowKeySet;
|
||||
@@ -33,7 +60,7 @@ export const useDocumentSelection = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateSelectionOrder = useCallback((nextSelection, interactedKeys = []) => {
|
||||
const updateSelectionOrder = useCallback((nextSelection: RowKey[], interactedKeys: RowKey[] = []) => {
|
||||
const nextSet = new Set(nextSelection);
|
||||
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
|
||||
const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index);
|
||||
@@ -65,13 +92,16 @@ export const useDocumentSelection = ({
|
||||
}, []);
|
||||
|
||||
const applySelection = useCallback(
|
||||
(rowKeys, { anchor, interactedKeys = [] } = {}) => {
|
||||
(
|
||||
rowKeys: Array<RowKey | null | undefined>,
|
||||
{ anchor, interactedKeys = [] }: ApplySelectionOptions = {},
|
||||
) => {
|
||||
const visibleRowKeySet = visibleRowKeySetRef.current;
|
||||
const unique = [];
|
||||
const unique: RowKey[] = [];
|
||||
|
||||
(rowKeys || []).forEach((key) => {
|
||||
(rowKeys || []).forEach((key) => {
|
||||
if (!key) return;
|
||||
let canonicalKey = null;
|
||||
let canonicalKey: RowKey | null | undefined = null;
|
||||
if (visibleRowKeySet.has(key)) {
|
||||
canonicalKey = key;
|
||||
} else if (isDocumentRowKey(key)) {
|
||||
@@ -99,7 +129,7 @@ export const useDocumentSelection = ({
|
||||
setSelectedEntries(unique);
|
||||
updateSelectionOrder(unique, interactedKeys);
|
||||
|
||||
const nextFocusedDocumentId = (() => {
|
||||
const nextFocusedDocumentId: DocumentId | null = (() => {
|
||||
if (focusedDocumentId) {
|
||||
const focusKey = resolveDocumentRowKey(focusedDocumentId);
|
||||
if (focusKey && unique.includes(focusKey)) {
|
||||
@@ -108,11 +138,11 @@ export const useDocumentSelection = ({
|
||||
}
|
||||
|
||||
if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) {
|
||||
return getRowId(resolvedAnchor) || null;
|
||||
return getRowId(resolvedAnchor) ?? null;
|
||||
}
|
||||
|
||||
const lastDocKey = [...unique].reverse().find(isDocumentRowKey);
|
||||
return lastDocKey ? getRowId(lastDocKey) || null : null;
|
||||
const lastDocKey = [...unique].reverse().find((key) => isDocumentRowKey(key)) ?? null;
|
||||
return lastDocKey ? getRowId(lastDocKey) ?? null : null;
|
||||
})();
|
||||
|
||||
setFocusedDocumentId(nextFocusedDocumentId);
|
||||
@@ -144,7 +174,7 @@ export const useDocumentSelection = ({
|
||||
}, [applySelection]);
|
||||
|
||||
const handleEntrySelection = useCallback(
|
||||
(rowKey, event) => {
|
||||
(rowKey: RowKey | null | undefined, event?: SelectionEventLike) => {
|
||||
const visibleRowKeySet = visibleRowKeySetRef.current;
|
||||
const navigableRowKeys = navigableRowKeysRef.current;
|
||||
if (!rowKey || !visibleRowKeySet.has(rowKey)) {
|
||||
@@ -170,8 +200,8 @@ export const useDocumentSelection = ({
|
||||
anchorKey = rowKey;
|
||||
}
|
||||
|
||||
let nextKeys = [];
|
||||
let interactedKeys = [];
|
||||
let nextKeys: RowKey[] = [];
|
||||
let interactedKeys: RowKey[] = [];
|
||||
|
||||
if (shiftKey && anchorKey) {
|
||||
const anchorIndex = navigableRowKeys.indexOf(anchorKey);
|
||||
@@ -213,7 +243,7 @@ export const useDocumentSelection = ({
|
||||
);
|
||||
|
||||
const promoteSelectionOrder = useCallback(
|
||||
(docId) => {
|
||||
(docId?: DocumentId | null) => {
|
||||
if (!docId) return;
|
||||
const rowKey = resolveDocumentRowKey(docId);
|
||||
if (!rowKey) return;
|
||||
+6
-6
@@ -10,7 +10,7 @@ const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field';
|
||||
const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction';
|
||||
const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants';
|
||||
|
||||
const readSessionStorage = (key) => {
|
||||
const readSessionStorage = (key: string): string | null => {
|
||||
try {
|
||||
return window.sessionStorage.getItem(key);
|
||||
} catch (error) {
|
||||
@@ -19,7 +19,7 @@ const readSessionStorage = (key) => {
|
||||
}
|
||||
};
|
||||
|
||||
const writeSessionStorage = (key, value) => {
|
||||
const writeSessionStorage = (key: string, value: string): void => {
|
||||
try {
|
||||
window.sessionStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
@@ -28,7 +28,7 @@ const writeSessionStorage = (key, value) => {
|
||||
};
|
||||
|
||||
export const useDocumentsPreferences = () => {
|
||||
const [documentsViewMode, setDocumentsViewModeState] = useState(() => {
|
||||
const [documentsViewMode, setDocumentsViewModeState] = useState<'list' | 'grid' | 'desk'>(() => {
|
||||
const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY);
|
||||
if (stored === 'grid' || stored === 'desk') {
|
||||
return stored;
|
||||
@@ -36,9 +36,9 @@ export const useDocumentsPreferences = () => {
|
||||
return 'list';
|
||||
});
|
||||
|
||||
const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode);
|
||||
const lastNonDeskViewRef = useRef<'list' | 'grid'>((documentsViewMode === 'desk' ? 'list' : documentsViewMode) as 'list' | 'grid');
|
||||
|
||||
const setDocumentsViewMode = useCallback((mode) => {
|
||||
const setDocumentsViewMode = useCallback((mode: string) => {
|
||||
const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list';
|
||||
setDocumentsViewModeState((previous) => {
|
||||
if (next !== previous) {
|
||||
@@ -75,7 +75,7 @@ export const useDocumentsPreferences = () => {
|
||||
writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection);
|
||||
}, [documentsSortDirection]);
|
||||
|
||||
const handleDocumentsSortFieldChange = useCallback((field) => {
|
||||
const handleDocumentsSortFieldChange = useCallback((field: string) => {
|
||||
const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD;
|
||||
setDocumentsSortField((previous) => (previous === nextField ? previous : nextField));
|
||||
}, []);
|
||||
@@ -1,10 +1,77 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import {
|
||||
TAG_FILTER_UNTAGGED,
|
||||
resolveDocumentRowKey,
|
||||
isDocumentRowKey,
|
||||
} from './appLayoutUtils';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
type DocumentLike = { id?: Identifier } & Record<string, unknown>;
|
||||
|
||||
type ApiClient = {
|
||||
get: <T = unknown>(url: string, config?: { params?: Record<string, unknown> }) => Promise<{ data: T }>;
|
||||
};
|
||||
|
||||
type AssetManagerLike = {
|
||||
hydrateDocuments: (payload: unknown[]) => DocumentLike[];
|
||||
};
|
||||
|
||||
type SelectionHelpers = {
|
||||
setSelectedEntries: (
|
||||
updater:
|
||||
| Array<Identifier | string>
|
||||
| ((prev: Array<Identifier | string>) => Array<Identifier | string>),
|
||||
) => void;
|
||||
setSelectionOrder: (
|
||||
updater:
|
||||
| Array<Identifier | string>
|
||||
| ((prev: Array<Identifier | string>) => Array<Identifier | string>),
|
||||
) => void;
|
||||
selectionOrderRef: { current: Array<Identifier | string> };
|
||||
selectionAnchorRef: { current: Identifier | string | null };
|
||||
setFocusedDocumentId: (
|
||||
updater: Identifier | null | ((prev: Identifier | null) => Identifier | null),
|
||||
) => void;
|
||||
};
|
||||
|
||||
interface UseDocumentsSearchArgs {
|
||||
api: ApiClient;
|
||||
assetManager: AssetManagerLike;
|
||||
token?: string | null;
|
||||
selectedFolder?: Identifier | 'root' | null;
|
||||
navigate?: (path: string, options?: { replace?: boolean }) => void;
|
||||
locationPathname?: string;
|
||||
isDocumentsRoute?: boolean;
|
||||
selectionHelpers: SelectionHelpers;
|
||||
searchIncludeDescendants?: boolean;
|
||||
documentsSortField?: string;
|
||||
documentsSortDirection?: string;
|
||||
notifyApiError: (error: unknown, message: string) => void;
|
||||
setLoading: (state: boolean) => void;
|
||||
setSearchIncludeDescendants: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface UseDocumentsSearchResult {
|
||||
searchQuery: string;
|
||||
setSearchQuery: Dispatch<SetStateAction<string>>;
|
||||
searchResults: DocumentLike[] | null;
|
||||
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | null>>;
|
||||
searchLoading: boolean;
|
||||
setSearchLoading: Dispatch<SetStateAction<boolean>>;
|
||||
activeTagFilters: Identifier[];
|
||||
setActiveTagFilters: Dispatch<SetStateAction<Identifier[]>>;
|
||||
activeCorrespondentFilters: Identifier[];
|
||||
setActiveCorrespondentFilters: Dispatch<SetStateAction<Identifier[]>>;
|
||||
toggleTagFilter: (tagId: Identifier) => void;
|
||||
toggleCorrespondentFilter: (correspondentId?: Identifier | null) => void;
|
||||
isFilterActive: boolean;
|
||||
clearFilters: () => void;
|
||||
handleSearchChange: (value: string) => void;
|
||||
handleSearchSubmit: () => void;
|
||||
}
|
||||
|
||||
const useDocumentsSearch = ({
|
||||
api,
|
||||
assetManager,
|
||||
@@ -20,14 +87,14 @@ const useDocumentsSearch = ({
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
setSearchIncludeDescendants,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
||||
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
||||
const [searchResults, setSearchResults] = useState(null);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
}: UseDocumentsSearchArgs): UseDocumentsSearchResult => {
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [activeTagFilters, setActiveTagFilters] = useState<Identifier[]>([]);
|
||||
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState<Identifier[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<DocumentLike[] | null>(null);
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||
|
||||
const toggleTagFilter = useCallback((tagId) => {
|
||||
const toggleTagFilter = useCallback((tagId: Identifier) => {
|
||||
if (!tagId) return;
|
||||
setActiveTagFilters((previous) => {
|
||||
if (tagId === TAG_FILTER_UNTAGGED) {
|
||||
@@ -41,7 +108,7 @@ const useDocumentsSearch = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleCorrespondentFilter = useCallback((correspondentId) => {
|
||||
const toggleCorrespondentFilter = useCallback((correspondentId?: Identifier | null) => {
|
||||
setActiveCorrespondentFilters((previous) => {
|
||||
if (!correspondentId) {
|
||||
return [];
|
||||
@@ -68,7 +135,7 @@ const useDocumentsSearch = ({
|
||||
setSearchIncludeDescendants,
|
||||
]);
|
||||
|
||||
const handleSearchChange = useCallback((value) => {
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchQuery(value);
|
||||
}, []);
|
||||
|
||||
@@ -98,7 +165,7 @@ const useDocumentsSearch = ({
|
||||
started = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {};
|
||||
const params: Record<string, unknown> = {};
|
||||
const trimmedQuery = searchQuery.trim();
|
||||
if (trimmedQuery.length) {
|
||||
params.query = trimmedQuery;
|
||||
@@ -131,7 +198,7 @@ const useDocumentsSearch = ({
|
||||
if (documentsSortDirection) {
|
||||
params.dir = documentsSortDirection;
|
||||
}
|
||||
const { data } = await api.get('/documents', { params });
|
||||
const { data } = await api.get<unknown[]>('/documents', { params });
|
||||
if (cancelled) return;
|
||||
|
||||
const results = assetManager.hydrateDocuments(data || []);
|
||||
@@ -148,11 +215,11 @@ const useDocumentsSearch = ({
|
||||
}
|
||||
|
||||
const resultKeys = results
|
||||
.map((doc) => resolveDocumentRowKey(doc.id))
|
||||
.filter(Boolean);
|
||||
.map((doc) => resolveDocumentRowKey(doc.id as Identifier))
|
||||
.filter(Boolean) as Array<Identifier | string>;
|
||||
|
||||
let targetKey = null;
|
||||
let nextSelectionKeys = [];
|
||||
let targetKey: Identifier | string | null = null;
|
||||
let nextSelectionKeys: Array<Identifier | string> = [];
|
||||
|
||||
selectionHelpers.setSelectedEntries((previous) => {
|
||||
const previousDocKeys = previous.filter(isDocumentRowKey);
|
||||
@@ -171,7 +238,7 @@ const useDocumentsSearch = ({
|
||||
selectionHelpers.setSelectionOrder(nextSelectionKeys);
|
||||
|
||||
selectionHelpers.setFocusedDocumentId((previous) => {
|
||||
if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) {
|
||||
if (previous && resultKeys.includes(resolveDocumentRowKey(previous as Identifier))) {
|
||||
return previous;
|
||||
}
|
||||
return null;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import TagsPanel from '../tags/TagsPanel';
|
||||
import CorrespondentsPanel from '../correspondents/CorrespondentsPanel';
|
||||
import PanelHeader from '../ui/PanelHeader';
|
||||
@@ -6,21 +7,56 @@ import PanelHeader from '../ui/PanelHeader';
|
||||
const TAGS_MODAL = 'tags';
|
||||
const CORRESPONDENTS_MODAL = 'correspondents';
|
||||
|
||||
interface TagRecord {
|
||||
id?: string | number;
|
||||
label?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CorrespondentRecord {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseManagementModalsArgs {
|
||||
locationPathname?: string;
|
||||
tags?: TagRecord[];
|
||||
refreshTags?: () => void | Promise<void>;
|
||||
onTagCreate?: (...args: any[]) => void | Promise<void>;
|
||||
onTagUpdate?: (...args: any[]) => void | Promise<void>;
|
||||
onTagDelete?: (...args: any[]) => void | Promise<void>;
|
||||
correspondents?: CorrespondentRecord[];
|
||||
refreshCorrespondents?: () => void | Promise<void>;
|
||||
onCorrespondentCreate?: (...args: any[]) => void | Promise<void>;
|
||||
onCorrespondentUpdate?: (...args: any[]) => void | Promise<void>;
|
||||
onCorrespondentDelete?: (...args: any[]) => void | Promise<void>;
|
||||
setStatusMessage?: (message: string, variant?: string) => void;
|
||||
}
|
||||
|
||||
interface UseManagementModalsResult {
|
||||
managementModals: ReactNode;
|
||||
openTagsModal: () => void;
|
||||
openCorrespondentsModal: () => void;
|
||||
closeActiveModal: () => void;
|
||||
activeModal: string | null;
|
||||
}
|
||||
|
||||
export const useManagementModals = ({
|
||||
locationPathname,
|
||||
tags,
|
||||
tags = [],
|
||||
refreshTags,
|
||||
onTagCreate,
|
||||
onTagUpdate,
|
||||
onTagDelete,
|
||||
correspondents,
|
||||
correspondents = [],
|
||||
refreshCorrespondents,
|
||||
onCorrespondentCreate,
|
||||
onCorrespondentUpdate,
|
||||
onCorrespondentDelete,
|
||||
setStatusMessage,
|
||||
}) => {
|
||||
const [activeModal, setActiveModal] = useState(null);
|
||||
}: UseManagementModalsArgs): UseManagementModalsResult => {
|
||||
const [activeModal, setActiveModal] = useState<string | null>(null);
|
||||
|
||||
const openTagsModal = useCallback(() => setActiveModal(TAGS_MODAL), []);
|
||||
const openCorrespondentsModal = useCallback(
|
||||
@@ -37,7 +73,7 @@ export const useManagementModals = ({
|
||||
if (!activeModal) {
|
||||
return undefined;
|
||||
}
|
||||
const handleKeyDown = (event) => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setActiveModal(null);
|
||||
+28
-9
@@ -1,17 +1,34 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useDocumentSelection } from './useDocumentSelection';
|
||||
|
||||
const identity = (value) => value;
|
||||
type RowKey = string;
|
||||
|
||||
interface SelectionEntry {
|
||||
rowKey?: RowKey;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface WorkspaceSelectionOptions {
|
||||
resolveDocumentRowKey?: (id: string | number) => RowKey | null | undefined;
|
||||
resolveFolderRowKey?: (id: string | number) => RowKey | null | undefined;
|
||||
isDocumentRowKey?: (key: RowKey | SelectionEntry) => boolean;
|
||||
isFolderRowKey?: (key: RowKey | SelectionEntry) => boolean;
|
||||
getRowId?: (key: RowKey | SelectionEntry) => string | number | null;
|
||||
onInspectDocument?: (id: string | number) => void;
|
||||
onInspectFolder?: (id: string | number) => void;
|
||||
}
|
||||
|
||||
const identity = <T,>(value: T) => value;
|
||||
|
||||
export const useWorkspaceSelection = ({
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
isDocumentRowKey,
|
||||
isFolderRowKey,
|
||||
getRowId,
|
||||
isDocumentRowKey = () => false,
|
||||
isFolderRowKey = () => false,
|
||||
getRowId = () => null,
|
||||
onInspectDocument = identity,
|
||||
onInspectFolder = identity,
|
||||
} = {}) => {
|
||||
}: WorkspaceSelectionOptions = {}) => {
|
||||
const selection = useDocumentSelection({
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
@@ -58,8 +75,10 @@ export const useWorkspaceSelection = ({
|
||||
);
|
||||
|
||||
const selectEntry = useCallback(
|
||||
(entry, event) => {
|
||||
const rowKey = entry?.rowKey ?? (typeof entry?.split === 'function' ? entry : null);
|
||||
(entry: SelectionEntry | string | null, event?: unknown) => {
|
||||
const rowKey = typeof entry === 'string'
|
||||
? entry
|
||||
: entry?.rowKey ?? null;
|
||||
if (!rowKey) return;
|
||||
handleEntrySelection(rowKey, event);
|
||||
},
|
||||
@@ -67,7 +86,7 @@ export const useWorkspaceSelection = ({
|
||||
);
|
||||
|
||||
const inspectDocument = useCallback(
|
||||
(documentId) => {
|
||||
(documentId?: string | number | null) => {
|
||||
if (!documentId) return;
|
||||
onInspectDocument(documentId);
|
||||
},
|
||||
@@ -75,7 +94,7 @@ export const useWorkspaceSelection = ({
|
||||
);
|
||||
|
||||
const inspectFolder = useCallback(
|
||||
(folderId) => {
|
||||
(folderId?: string | number | null) => {
|
||||
if (!folderId) return;
|
||||
onInspectFolder(folderId);
|
||||
},
|
||||
+56
-10
@@ -1,17 +1,63 @@
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { SidebarExpandIcon } from '../ui/icons';
|
||||
import { createDocumentsSurface } from '../documents/DocumentsPanel';
|
||||
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
|
||||
import createDesktopSurface from '../desktop/createDesktopSurface';
|
||||
import { usePanelManager } from './PanelManagerContext';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
type EnsureAssetUrl = (docId: Identifier, asset: unknown, options?: Record<string, unknown>) => Promise<unknown> | void;
|
||||
type EnsurePreviewData = (docId: Identifier, options?: Record<string, unknown>) => Promise<unknown>;
|
||||
type GetDocumentAsset = (document: unknown, assetType: string) => unknown;
|
||||
type ResolveApiPath = (path: string) => string;
|
||||
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
|
||||
|
||||
type DocumentsSurface = ReturnType<typeof createDocumentsSurface> | null;
|
||||
type PreviewSurface = ReturnType<typeof createDocumentViewerSurface> | null;
|
||||
type DesktopSurface = ReturnType<typeof createDesktopSurface> | null;
|
||||
type WorkspaceSurface = DocumentsSurface | PreviewSurface | DesktopSurface | null;
|
||||
|
||||
interface Breadcrumb {
|
||||
id?: Identifier;
|
||||
name?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface UseWorkspaceSurfaceArgs {
|
||||
sidebarHidden?: boolean;
|
||||
onExpandSidebar?: () => void;
|
||||
documentsTableProps?: Record<string, any> | null;
|
||||
detailPanelProps?: (Record<string, any> & { onClose?: () => void }) | null;
|
||||
detailPanelOpen?: boolean;
|
||||
viewMode?: string;
|
||||
deskWorkspaceProps?: Record<string, any> | null;
|
||||
previewWorkspaceDocument?: unknown;
|
||||
previewWorkspaceEntry?: unknown;
|
||||
previewDocumentId?: Identifier | null;
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
ensurePreviewData?: EnsurePreviewData;
|
||||
getDocumentAsset?: GetDocumentAsset;
|
||||
resolveApiPath?: ResolveApiPath;
|
||||
notifyApiError?: NotifyApiError;
|
||||
closeDocumentPreview?: () => void;
|
||||
parentBreadcrumb?: Breadcrumb | null;
|
||||
onNavigateParent?: () => void;
|
||||
}
|
||||
|
||||
interface UseWorkspaceSurfaceResult {
|
||||
surface: WorkspaceSurface;
|
||||
}
|
||||
|
||||
export const useWorkspaceSurface = ({
|
||||
sidebarHidden,
|
||||
sidebarHidden = false,
|
||||
onExpandSidebar,
|
||||
documentsTableProps,
|
||||
detailPanelProps,
|
||||
detailPanelOpen,
|
||||
viewMode,
|
||||
detailPanelOpen = false,
|
||||
viewMode = 'list',
|
||||
deskWorkspaceProps,
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
@@ -24,7 +70,7 @@ export const useWorkspaceSurface = ({
|
||||
closeDocumentPreview,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent,
|
||||
}) => {
|
||||
}: UseWorkspaceSurfaceArgs): UseWorkspaceSurfaceResult => {
|
||||
const { registerDetailCloseHandler, setDetailActive } = usePanelManager();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,7 +84,7 @@ export const useWorkspaceSurface = ({
|
||||
return () => setDetailActive(false);
|
||||
}, [detailPanelOpen, setDetailActive]);
|
||||
|
||||
const renderSidebarToggle = useCallback(() => {
|
||||
const renderSidebarToggle = useCallback<() => ReactNode>(() => {
|
||||
if (!sidebarHidden) {
|
||||
return null;
|
||||
}
|
||||
@@ -55,7 +101,7 @@ export const useWorkspaceSurface = ({
|
||||
);
|
||||
}, [sidebarHidden, onExpandSidebar]);
|
||||
|
||||
const documentsSurface = useMemo(() => {
|
||||
const documentsSurface = useMemo<DocumentsSurface>(() => {
|
||||
if (!documentsTableProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -78,7 +124,7 @@ export const useWorkspaceSurface = ({
|
||||
|
||||
const showPreviewWorkspace = Boolean(previewDocumentId);
|
||||
|
||||
const previewSurface = useMemo(() => {
|
||||
const previewSurface = useMemo<PreviewSurface>(() => {
|
||||
if (!showPreviewWorkspace) {
|
||||
return null;
|
||||
}
|
||||
@@ -132,7 +178,7 @@ export const useWorkspaceSurface = ({
|
||||
detailPanelProps,
|
||||
]);
|
||||
|
||||
const workspaceSurface = useMemo(() => {
|
||||
const workspaceSurface = useMemo<DesktopSurface>(() => {
|
||||
if (viewMode !== 'desk') {
|
||||
return null;
|
||||
}
|
||||
@@ -154,7 +200,7 @@ export const useWorkspaceSurface = ({
|
||||
detailPanelOpen,
|
||||
]);
|
||||
|
||||
const surface = useMemo(() => {
|
||||
const surface = useMemo<WorkspaceSurface>(() => {
|
||||
if (showPreviewWorkspace) {
|
||||
return previewSurface;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
export const AppShellContext = React.createContext(null);
|
||||
export type AppShellContextValue = Record<string, unknown>;
|
||||
|
||||
export const useAppShell = () => {
|
||||
export const AppShellContext = React.createContext<AppShellContextValue | null>(null);
|
||||
|
||||
export const useAppShell = (): AppShellContextValue => {
|
||||
const context = React.useContext(AppShellContext);
|
||||
if (!context) {
|
||||
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
export const getAssetFromGroup = (assets, assetType) => {
|
||||
import type { AxiosInstance } from 'axios';
|
||||
|
||||
export type Identifier = string | number;
|
||||
|
||||
export interface AssetObject {
|
||||
ordinal?: number;
|
||||
url?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
expires_at?: number | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AssetLike {
|
||||
id?: Identifier;
|
||||
asset_type?: string;
|
||||
cardinality?: number | null;
|
||||
url?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
expiresAt?: number | null;
|
||||
assets?: Record<string, AssetLike> | AssetLike[] | null;
|
||||
objects?: AssetObject[] | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DocumentVersionLike {
|
||||
assets?: Record<string, AssetLike> | AssetLike[] | null;
|
||||
metadata?: Record<string, unknown> & { page_count?: number } | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DocumentLike {
|
||||
id?: Identifier;
|
||||
current_version?: DocumentVersionLike | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type EnsureAssetUrl = (
|
||||
documentId: Identifier,
|
||||
asset: AssetLike,
|
||||
options?: { start?: number | null; limit?: number | null; force?: boolean; [key: string]: unknown },
|
||||
) => Promise<unknown>;
|
||||
|
||||
export type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null | undefined;
|
||||
|
||||
export const getAssetFromGroup = (
|
||||
assets: AssetLike[] | Record<string, AssetLike> | null | undefined,
|
||||
assetType: string,
|
||||
): AssetLike | null => {
|
||||
if (!assetType || !assets) {
|
||||
return null;
|
||||
}
|
||||
@@ -17,7 +64,7 @@ export const getAssetFromVersion = (currentVersion, assetType) => {
|
||||
return getAssetFromGroup(currentVersion.assets, assetType);
|
||||
};
|
||||
|
||||
const normalizeAssetObjects = (objects) => {
|
||||
const normalizeAssetObjects = (objects?: AssetObject[] | null): AssetObject[] => {
|
||||
if (!Array.isArray(objects)) {
|
||||
return [];
|
||||
}
|
||||
@@ -27,31 +74,42 @@ const normalizeAssetObjects = (objects) => {
|
||||
.sort((a, b) => a.ordinal - b.ordinal);
|
||||
};
|
||||
|
||||
const mergeAssetObjects = (existingObjects, incomingObjects) => {
|
||||
const merged = new Map();
|
||||
const mergeAssetObjects = (
|
||||
existingObjects?: AssetObject[] | null,
|
||||
incomingObjects?: AssetObject[] | null,
|
||||
): AssetObject[] => {
|
||||
const merged = new Map<number, AssetObject>();
|
||||
|
||||
normalizeAssetObjects(existingObjects).forEach((entry) => {
|
||||
merged.set(entry.ordinal, { ...entry });
|
||||
if (typeof entry.ordinal === 'number') {
|
||||
merged.set(entry.ordinal, { ...entry });
|
||||
}
|
||||
});
|
||||
|
||||
normalizeAssetObjects(incomingObjects).forEach((entry) => {
|
||||
const current = merged.get(entry.ordinal) || {};
|
||||
merged.set(entry.ordinal, { ...current, ...entry });
|
||||
if (typeof entry.ordinal === 'number') {
|
||||
const current = merged.get(entry.ordinal) || {};
|
||||
merged.set(entry.ordinal, { ...current, ...entry });
|
||||
}
|
||||
});
|
||||
|
||||
return [...merged.entries()]
|
||||
return Array.from(merged.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, value]) => value);
|
||||
};
|
||||
|
||||
export class AssetView {
|
||||
constructor(asset) {
|
||||
asset: AssetLike | null;
|
||||
private _objectsRef: AssetObject[] | null;
|
||||
private _sortedObjects: AssetObject[];
|
||||
|
||||
constructor(asset?: AssetLike | null) {
|
||||
this.asset = asset || null;
|
||||
this._objectsRef = null;
|
||||
this._sortedObjects = [];
|
||||
}
|
||||
|
||||
getCardinality() {
|
||||
getCardinality(): number {
|
||||
if (!this.asset) {
|
||||
return 0;
|
||||
}
|
||||
@@ -69,7 +127,7 @@ export class AssetView {
|
||||
return this.asset.metadata ? 1 : 0;
|
||||
}
|
||||
|
||||
getObjects() {
|
||||
getObjects(): AssetObject[] {
|
||||
if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -83,7 +141,7 @@ export class AssetView {
|
||||
return this._sortedObjects;
|
||||
}
|
||||
|
||||
getObject(ordinal = 1) {
|
||||
getObject(ordinal = 1): AssetObject | null {
|
||||
const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal);
|
||||
if (fromObjects) {
|
||||
return fromObjects;
|
||||
@@ -103,30 +161,40 @@ export class AssetView {
|
||||
return null;
|
||||
}
|
||||
|
||||
getPrimaryObject() {
|
||||
getPrimaryObject(): AssetObject | null {
|
||||
return this.getObject(1);
|
||||
}
|
||||
|
||||
getPrimaryMetadata() {
|
||||
getPrimaryMetadata(): Record<string, unknown> | null {
|
||||
return this.getPrimaryObject()?.metadata || null;
|
||||
}
|
||||
|
||||
getPrimaryUrl() {
|
||||
getPrimaryUrl(): string | null {
|
||||
return this.getPrimaryObject()?.url || null;
|
||||
}
|
||||
|
||||
hasObject(ordinal) {
|
||||
hasObject(ordinal: number): boolean {
|
||||
return Boolean(this.getObject(ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
export const createAssetView = (asset) => new AssetView(asset);
|
||||
export const createAssetView = (asset?: AssetLike | null): AssetView => new AssetView(asset);
|
||||
|
||||
export const resolveDocumentAssetUrl = (
|
||||
doc,
|
||||
type,
|
||||
{ ensureAssetUrl, getAsset, ensureOptions, objectOrdinal = 1 } = {},
|
||||
) => {
|
||||
doc: DocumentLike | null | undefined,
|
||||
type: string,
|
||||
{
|
||||
ensureAssetUrl,
|
||||
getAsset,
|
||||
ensureOptions,
|
||||
objectOrdinal = 1,
|
||||
}: {
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
getAsset?: GetAsset;
|
||||
ensureOptions?: { start?: number; limit?: number; [key: string]: unknown };
|
||||
objectOrdinal?: number;
|
||||
} = {},
|
||||
): string | null => {
|
||||
if (!doc || !type) {
|
||||
return null;
|
||||
}
|
||||
@@ -148,7 +216,7 @@ export const resolveDocumentAssetUrl = (
|
||||
}
|
||||
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
|
||||
const force = Boolean(url && expiresAt && expiresAt <= now);
|
||||
const options = {
|
||||
const options: { force: boolean; start?: number | null; limit?: number | null; [key: string]: unknown } = {
|
||||
force,
|
||||
start: objectOrdinal,
|
||||
limit: 1,
|
||||
@@ -166,24 +234,29 @@ export const resolveDocumentAssetUrl = (
|
||||
};
|
||||
|
||||
class AssetManager {
|
||||
constructor({ api, assetPresignTtlMs }) {
|
||||
api: AxiosInstance | null;
|
||||
assetPresignTtlMs: number;
|
||||
assetCache: Map<Identifier, AssetLike>;
|
||||
assetInflight: Map<string, Promise<AssetLike | null>>;
|
||||
|
||||
constructor({ api, assetPresignTtlMs }: { api: AxiosInstance | null; assetPresignTtlMs: number }) {
|
||||
this.api = api;
|
||||
this.assetPresignTtlMs = assetPresignTtlMs;
|
||||
this.assetCache = new Map();
|
||||
this.assetInflight = new Map();
|
||||
}
|
||||
|
||||
setApi(api) {
|
||||
setApi(api: AxiosInstance | null) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
rememberAsset(entry) {
|
||||
rememberAsset(entry: AssetLike | null | undefined) {
|
||||
if (entry?.id) {
|
||||
this.assetCache.set(entry.id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
hydrateAsset(asset) {
|
||||
hydrateAsset(asset: AssetLike | null | undefined): AssetLike | null | undefined {
|
||||
if (!asset || !asset.id) {
|
||||
return asset;
|
||||
}
|
||||
@@ -213,7 +286,7 @@ class AssetManager {
|
||||
return merged;
|
||||
}
|
||||
|
||||
hydrateDocument(document) {
|
||||
hydrateDocument(document: DocumentLike | null | undefined): DocumentLike | null | undefined {
|
||||
if (!document) {
|
||||
return document;
|
||||
}
|
||||
@@ -252,18 +325,18 @@ class AssetManager {
|
||||
return document;
|
||||
}
|
||||
|
||||
const nextCurrentVersion = { ...currentVersion, assets: nextAssets };
|
||||
const nextCurrentVersion: DocumentVersionLike = { ...currentVersion, assets: nextAssets };
|
||||
return { ...document, current_version: nextCurrentVersion };
|
||||
}
|
||||
|
||||
hydrateDocuments(documents) {
|
||||
hydrateDocuments(documents: DocumentLike[] | null | undefined) {
|
||||
if (!Array.isArray(documents)) {
|
||||
return documents;
|
||||
}
|
||||
return documents.map((doc) => this.hydrateDocument(doc));
|
||||
}
|
||||
|
||||
hydrateDetail(detail) {
|
||||
hydrateDetail(detail: { document?: DocumentLike; assets?: AssetLike[] } | null | undefined) {
|
||||
if (!detail) {
|
||||
return detail;
|
||||
}
|
||||
@@ -292,7 +365,10 @@ class AssetManager {
|
||||
return changed ? next : detail;
|
||||
}
|
||||
|
||||
hydrateFolderContents(contents) {
|
||||
hydrateFolderContents(contents: {
|
||||
documents?: DocumentLike[];
|
||||
document?: DocumentLike;
|
||||
} | null | undefined) {
|
||||
if (!contents) {
|
||||
return contents;
|
||||
}
|
||||
@@ -306,7 +382,11 @@ class AssetManager {
|
||||
return next;
|
||||
}
|
||||
|
||||
ensureAsset(documentId, asset, { force = false, start = null, limit = null } = {}) {
|
||||
ensureAsset(
|
||||
documentId: Identifier | null | undefined,
|
||||
asset: AssetLike | null | undefined,
|
||||
{ force = false, start = null, limit = null }: { force?: boolean; start?: number | null; limit?: number | null } = {},
|
||||
): Promise<AssetLike | null> {
|
||||
if (!documentId || !asset?.id) {
|
||||
return Promise.resolve(asset || null);
|
||||
}
|
||||
@@ -361,7 +441,7 @@ class AssetManager {
|
||||
return Promise.reject(new Error('AssetManager API client is not configured.'));
|
||||
}
|
||||
|
||||
const params = {};
|
||||
const params: Record<string, number> = {};
|
||||
if (Number.isInteger(start) && start > 0) {
|
||||
params.start = start;
|
||||
}
|
||||
@@ -371,7 +451,7 @@ class AssetManager {
|
||||
|
||||
const requestConfig = Object.keys(params).length ? { params } : undefined;
|
||||
|
||||
const request = this.api
|
||||
const request: Promise<AssetLike | null> = this.api
|
||||
.get(`/assets/${asset.id}`, requestConfig)
|
||||
.then(({ data }) => {
|
||||
const incomingObjects = Array.isArray(data.objects) ? data.objects : [];
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 128 128" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:bevel;">
|
||||
<g transform="matrix(1,0,0,1,-117.44,-234.88)">
|
||||
<g transform="matrix(0.500026,0,0,0.500026,90.3513,215.477)">
|
||||
<g>
|
||||
<path d="M168.083,199.099L72.029,143.641" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(0.556777,0,0,0.556777,80.018,205.619)">
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M132.168,192.252L104.103,176.048" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M132.168,206.14L104.103,189.937" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M167.57,268.244L68.737,211.183" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M104.103,176.048L103.474,175.723L102.852,175.478L102.245,175.315L101.657,175.236L101.096,175.242L100.568,175.334L100.079,175.509L99.634,175.766L99.238,176.102L98.895,176.514L98.609,176.996L98.383,177.545L98.22,178.152L98.122,178.814L98.089,179.52L98.122,180.265L98.22,181.04L98.383,181.836L98.609,182.645L98.895,183.458L99.238,184.265L99.634,185.059L100.079,185.83L100.568,186.57L101.096,187.271L101.657,187.924L102.245,188.524L102.852,189.063L103.474,189.536L104.103,189.937" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M132.168,206.14L132.797,206.465L133.418,206.71L134.026,206.873L134.614,206.952L135.175,206.946L135.703,206.855L136.192,206.68L136.637,206.423L137.034,206.087L137.376,205.675L137.662,205.193L137.888,204.644L138.051,204.036L138.149,203.375L138.182,202.668L138.149,201.923L138.051,201.149L137.888,200.352L137.662,199.543L137.376,198.731L137.034,197.923L136.637,197.13L136.192,196.359L135.703,195.619L135.175,194.918L134.614,194.264L134.026,193.664L133.418,193.125L132.797,192.653L132.168,192.252" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M294.73,124.536L196.717,67.948" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M256.457,120.494L228.391,104.291" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M256.457,134.382L228.391,118.179" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(0.556777,0,0,0.556777,80.018,205.619)">
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M196.317,137.16L196.317,71.884" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M256.457,134.382L257.086,134.707L257.707,134.953L258.314,135.115L258.902,135.194L259.464,135.188L259.992,135.097L260.479,134.922L260.926,134.665L261.322,134.329L261.665,133.917L261.95,133.435L262.176,132.886L262.339,132.278L262.438,131.617L262.471,130.91L262.438,130.165L262.339,129.391L262.176,128.594L261.95,127.786L261.665,126.973L261.322,126.165L260.926,125.372L260.479,124.601L259.992,123.861L259.464,123.16L258.902,122.507L258.314,121.907L257.707,121.368L257.086,120.895L256.457,120.494" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M228.391,104.291L227.762,103.966L227.141,103.72L226.533,103.557L225.945,103.479L225.384,103.485L224.856,103.576L224.367,103.751L223.922,104.008L223.526,104.344L223.183,104.756L222.897,105.238L222.672,105.787L222.509,106.395L222.41,107.056L222.377,107.763L222.41,108.508L222.509,109.282L222.672,110.078L222.897,110.887L223.183,111.7L223.526,112.507L223.922,113.301L224.367,114.072L224.856,114.812L225.384,115.513L225.945,116.166L226.533,116.766L227.141,117.306L227.762,117.778L228.391,118.179" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M295.747,125.124L295.747,193.77" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M167.851,198.965L295.747,125.124" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M167.851,266.435L167.851,198.965" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M295.747,194.567L167.851,268.406" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M68.42,141.558L196.317,67.717" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(1.9164,0,0,1.9164,-51.1148,-197.163)">
|
||||
<path d="M68.42,211.001L68.42,178.595" style="fill:none;stroke:currentColor;stroke-width:3.75px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M196.317,137.16L234.951,159.465" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.898073,0,0,0.898073,18.5592,17.7055)">
|
||||
<path d="M130.133,175.372L196.317,137.16" style="fill:none;stroke:currentColor;stroke-width:8px;"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
+31
-9
@@ -1,4 +1,26 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { FormEvent, KeyboardEvent } from 'react';
|
||||
|
||||
interface CorrespondentUsage {
|
||||
total?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CorrespondentEntry {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
usage?: CorrespondentUsage;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CorrespondentsPanelProps {
|
||||
correspondents?: CorrespondentEntry[];
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
onCreate: (payload: { name: string }) => Promise<CorrespondentEntry | void>;
|
||||
onUpdate: (id: string | number, payload: { name: string }) => Promise<void>;
|
||||
onDelete: (id: string | number) => Promise<void>;
|
||||
onNotify?: (message: string, variant?: string) => void;
|
||||
}
|
||||
|
||||
function CorrespondentsPanel({
|
||||
correspondents = [],
|
||||
@@ -7,15 +29,15 @@ function CorrespondentsPanel({
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onNotify,
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
}: CorrespondentsPanelProps) {
|
||||
const [editingId, setEditingId] = useState<string | number | null>(null);
|
||||
const [draftName, setDraftName] = useState('');
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
const [deletingId, setDeletingId] = useState<string | number | null>(null);
|
||||
|
||||
const startEdit = useCallback((correspondent) => {
|
||||
const startEdit = useCallback((correspondent: CorrespondentEntry) => {
|
||||
setEditingId(correspondent.id);
|
||||
setDraftName(correspondent.name);
|
||||
}, []);
|
||||
@@ -46,7 +68,7 @@ function CorrespondentsPanel({
|
||||
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (correspondent) => {
|
||||
async (correspondent: CorrespondentEntry) => {
|
||||
if (!correspondent?.id) return;
|
||||
setDeletingId(correspondent.id);
|
||||
try {
|
||||
@@ -65,7 +87,7 @@ function CorrespondentsPanel({
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (event) => {
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const trimmed = createName.trim();
|
||||
if (!trimmed) {
|
||||
@@ -87,7 +109,7 @@ function CorrespondentsPanel({
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event) => {
|
||||
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
@@ -99,7 +121,7 @@ function CorrespondentsPanel({
|
||||
[handleSave, cancelEdit],
|
||||
);
|
||||
|
||||
const renderUsage = useCallback((usage) => {
|
||||
const renderUsage = useCallback((usage?: CorrespondentUsage) => {
|
||||
if (!usage) {
|
||||
return '0';
|
||||
}
|
||||
+65
-9
@@ -4,7 +4,46 @@ import { resolveCorrespondents } from '../documents/correspondents';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { preventAll } from './events';
|
||||
|
||||
const DesktopDocumentCard = ({
|
||||
type DocumentLike = {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
tags?: Array<{ id?: string | number; label?: string; color?: string | null }>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
interface PendingRemovalTag {
|
||||
docId?: string | number;
|
||||
tagId?: string | number;
|
||||
}
|
||||
|
||||
interface DesktopDocumentCardProps {
|
||||
doc: DocumentLike;
|
||||
style?: React.CSSProperties;
|
||||
shouldLoad?: boolean;
|
||||
dragging?: boolean;
|
||||
matchesFilter?: boolean;
|
||||
tagTargetActive?: boolean;
|
||||
tagTargetPending?: boolean;
|
||||
selected?: boolean;
|
||||
docTagTokens?: string;
|
||||
ensureAssetUrl?: (...args: any[]) => Promise<unknown>;
|
||||
getDocumentAsset?: (...args: any[]) => unknown;
|
||||
handleNavigatorSnapshot?: (...args: any[]) => void;
|
||||
cardPointerHandlers?: React.HTMLAttributes<HTMLDivElement>;
|
||||
onDocumentOpen?: (id: string | number) => void;
|
||||
onTagDragEnter?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
|
||||
onTagDragOver?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
|
||||
onTagDragLeave?: (event: React.DragEvent<HTMLDivElement>, docId: string | number) => void;
|
||||
onTagDrop?: (event: React.DragEvent<HTMLDivElement>, doc: DocumentLike) => void;
|
||||
onDocTagPointerDown?: (event: React.PointerEvent<HTMLElement>, doc: DocumentLike, tag: any) => void;
|
||||
onDocTagDragStart?: (event: React.DragEvent<HTMLElement>, doc: DocumentLike, tag: any) => void;
|
||||
onDocTagDrag?: (event: React.DragEvent<HTMLElement>) => void;
|
||||
onDocTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
|
||||
pendingRemovalTag?: PendingRemovalTag | null;
|
||||
registerNode?: (node: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
||||
doc,
|
||||
style,
|
||||
shouldLoad,
|
||||
@@ -52,12 +91,29 @@ const DesktopDocumentCard = ({
|
||||
data-doc-id={doc.id}
|
||||
data-tag-ids={dataTagIds}
|
||||
aria-hidden={ariaHidden}
|
||||
ref={registerNode}
|
||||
ref={registerNode ?? undefined}
|
||||
{...cardPointerHandlers}
|
||||
onDragEnter={(event) => onTagDragEnter(event, doc.id)}
|
||||
onDragOver={(event) => onTagDragOver(event, doc.id)}
|
||||
onDragLeave={(event) => onTagDragLeave(event, doc.id)}
|
||||
onDrop={(event) => onTagDrop(event, doc)}
|
||||
onDragEnter={(event) => {
|
||||
if (doc?.id == null) {
|
||||
return;
|
||||
}
|
||||
onTagDragEnter?.(event, doc.id);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (doc?.id == null) {
|
||||
return;
|
||||
}
|
||||
onTagDragOver?.(event, doc.id);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (doc?.id == null) {
|
||||
return;
|
||||
}
|
||||
onTagDragLeave?.(event, doc.id);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
onTagDrop?.(event, doc);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
preventAll(event);
|
||||
@@ -107,11 +163,11 @@ const DesktopDocumentCard = ({
|
||||
draggable
|
||||
data-desk-tag-chip="true"
|
||||
onPointerDownCapture={(event) => {
|
||||
onDocTagPointerDown(event, doc, tag);
|
||||
onDocTagPointerDown?.(event, doc, tag);
|
||||
}}
|
||||
onDragStart={(event) => onDocTagDragStart(event, doc, tag)}
|
||||
onDragStart={(event) => onDocTagDragStart?.(event, doc, tag)}
|
||||
onDrag={onDocTagDrag}
|
||||
onDragEnd={(event) => onDocTagDragEnd(event)}
|
||||
onDragEnd={(event) => onDocTagDragEnd?.(event)}
|
||||
>
|
||||
<span className="tag-chip__label">{tag.label}</span>
|
||||
</span>
|
||||
+52
-5
@@ -1,8 +1,56 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||
import { preventAll } from './events';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AssetLike {
|
||||
id?: Identifier;
|
||||
cardinality?: number;
|
||||
url?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
objects?: Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type EnsureAssetUrl = (
|
||||
documentId: Identifier,
|
||||
asset: AssetLike,
|
||||
options?: { start?: number; limit?: number; [key: string]: unknown },
|
||||
) => Promise<unknown>;
|
||||
|
||||
type GetDocumentAsset = (document: DocumentLike | null | undefined, assetType: string) => AssetLike | null | undefined;
|
||||
|
||||
interface NavigatorSnapshot {
|
||||
url: string | null;
|
||||
alt?: string;
|
||||
canGoPrev: boolean;
|
||||
canGoNext: boolean;
|
||||
goPrev?: () => void;
|
||||
goNext?: () => void;
|
||||
ordinal: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
interface DesktopPreviewCardProps {
|
||||
doc: DocumentLike | null;
|
||||
title?: string;
|
||||
ensureAssetUrl?: EnsureAssetUrl | null;
|
||||
getDocumentAsset: GetDocumentAsset;
|
||||
prefetch?: number;
|
||||
onNavigatorSnapshot?: (docId: Identifier, snapshot: NavigatorSnapshot | null) => void;
|
||||
shouldLoad?: boolean;
|
||||
}
|
||||
|
||||
const DesktopPreviewCard = ({
|
||||
doc,
|
||||
title,
|
||||
@@ -11,7 +59,7 @@ const DesktopPreviewCard = ({
|
||||
prefetch = 3,
|
||||
onNavigatorSnapshot,
|
||||
shouldLoad = true,
|
||||
}) => {
|
||||
}: DesktopPreviewCardProps): JSX.Element => {
|
||||
const navigator = useAssetNavigator({
|
||||
document: doc,
|
||||
assetType: 'preview',
|
||||
@@ -23,8 +71,8 @@ const DesktopPreviewCard = ({
|
||||
const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator;
|
||||
const docId = doc?.id ?? null;
|
||||
|
||||
const metadataWidth = Number(currentMetadata?.width);
|
||||
const metadataHeight = Number(currentMetadata?.height);
|
||||
const metadataWidth = Number((currentMetadata as { width?: number } | null)?.width);
|
||||
const metadataHeight = Number((currentMetadata as { height?: number } | null)?.height);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onNavigatorSnapshot || !docId) {
|
||||
@@ -143,4 +191,3 @@ const DesktopPreviewCard = ({
|
||||
};
|
||||
|
||||
export default DesktopPreviewCard;
|
||||
|
||||
+231
-49
@@ -28,9 +28,168 @@ import '../styles/workspace/workspace-layout.css';
|
||||
import '../styles/workspace/workspace-items.css';
|
||||
import '../styles/workspace/workspace-cards.css';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null | undefined;
|
||||
|
||||
export interface DeskDocument {
|
||||
id?: Identifier | null;
|
||||
title?: string;
|
||||
tags?: TagLike[] | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface NavigatorSnapshot {
|
||||
url: string | null;
|
||||
alt?: string | null;
|
||||
canGoPrev?: boolean;
|
||||
canGoNext?: boolean;
|
||||
goPrev?: () => void;
|
||||
goNext?: () => void;
|
||||
ordinal?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
interface OverlayOriginTransform {
|
||||
rotation: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
}
|
||||
|
||||
interface OverlayDisplay extends NavigatorSnapshot {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface DocumentSizeInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
source?: 'snapshot' | 'metadata' | 'fallback';
|
||||
}
|
||||
|
||||
interface PreviewMetadataEntry {
|
||||
docId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface DragTransformOverride {
|
||||
centerX?: number;
|
||||
centerY?: number;
|
||||
rotation?: number;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
interface DragSettings {
|
||||
canvasPadding: number;
|
||||
defaultCanvasWidth: number;
|
||||
defaultCanvasHeight: number;
|
||||
debugDrag?: boolean;
|
||||
}
|
||||
|
||||
interface LayoutEntry {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
z: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
type WorkspaceSnapshotState = {
|
||||
layout: Map<string, LayoutEntry>;
|
||||
canvasSize: { width: number; height: number };
|
||||
visibleDocIds: Set<string>;
|
||||
draggingId: string | null;
|
||||
tagDropTargetId: string | null;
|
||||
pendingTagDocId: string | null;
|
||||
pendingRemovalTag: unknown;
|
||||
initialLoadDone: boolean;
|
||||
};
|
||||
|
||||
interface DesktopWorkspaceProps {
|
||||
documents?: DeskDocument[];
|
||||
searchResults?: DeskDocument[] | null;
|
||||
onDocumentOpen?: (docId: Identifier | null, options?: Record<string, unknown>) => void;
|
||||
onInspectDocument?: (...args: unknown[]) => void;
|
||||
onEntryPointer?: (...args: unknown[]) => void;
|
||||
onDocumentStackSelect?: (docIds: Identifier[]) => void;
|
||||
onPromoteSelection?: (...args: unknown[]) => void;
|
||||
onAssignTagToDocument?: (...args: unknown[]) => void;
|
||||
onRemoveTagFromDocument?: (...args: unknown[]) => void;
|
||||
ensureAssetUrl?: (...args: unknown[]) => Promise<unknown> | unknown;
|
||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||
activeTagIds?: Array<Identifier | null | undefined>;
|
||||
selectedDocumentIds?: Identifier[];
|
||||
onClearSelection?: () => void;
|
||||
detailPanelOpen?: boolean;
|
||||
onCloseDetailPanel?: () => void;
|
||||
tenantId?: Identifier | null;
|
||||
viewId?: string | null;
|
||||
}
|
||||
|
||||
interface DesktopWorkspaceViewProps {
|
||||
engine: WorkspaceEngine;
|
||||
items: DeskDocument[];
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
handleCanvasDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
handleCanvasDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
handleCanvasDrop: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
ensureDocumentSize: (doc: DeskDocument | null | undefined) => DocumentSizeInfo | null;
|
||||
layoutSnapshot: Map<string, LayoutEntry>;
|
||||
layoutRef: React.MutableRefObject<Map<string, LayoutEntry>>;
|
||||
dragTransformsRef: React.MutableRefObject<Map<string, DragTransformOverride>>;
|
||||
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
visibleDocIds: Set<string>;
|
||||
draggingId: string | null;
|
||||
tagDropTargetId: string | null;
|
||||
pendingTagDocId: string | null;
|
||||
pendingRemovalTag: unknown;
|
||||
onDocumentOpen?: DesktopWorkspaceProps['onDocumentOpen'];
|
||||
ensureAssetUrl?: DesktopWorkspaceProps['ensureAssetUrl'];
|
||||
getDocumentAsset?: DesktopWorkspaceProps['getDocumentAsset'];
|
||||
handleNavigatorSnapshot: (docId: Identifier | null, snapshot: NavigatorSnapshot | null) => void;
|
||||
activeTagSet: Set<string>;
|
||||
handleTagDragEnterDoc: (...args: unknown[]) => void;
|
||||
handleTagDragOverDoc: (...args: unknown[]) => void;
|
||||
handleTagDragLeaveDoc: (...args: unknown[]) => void;
|
||||
handleTagDropOnDoc: (...args: unknown[]) => void;
|
||||
handleDocTagPointerDown: (...args: unknown[]) => void;
|
||||
handleDocTagDragStart: (...args: unknown[]) => void;
|
||||
handleDocTagDrag: (...args: unknown[]) => void;
|
||||
handleDocTagDragEnd: (...args: unknown[]) => void;
|
||||
overlayDisplay: OverlayDisplay | null;
|
||||
closeOverlay: () => void;
|
||||
overlayOriginRect: DOMRect | null;
|
||||
overlayOriginTransform: OverlayOriginTransform | null;
|
||||
onEntryPointer?: DesktopWorkspaceProps['onEntryPointer'];
|
||||
onDocumentStackSelect?: DesktopWorkspaceProps['onDocumentStackSelect'];
|
||||
onPromoteSelection?: DesktopWorkspaceProps['onPromoteSelection'];
|
||||
selectedDocumentIds: Identifier[];
|
||||
onClearSelection?: DesktopWorkspaceProps['onClearSelection'];
|
||||
detailPanelOpen: boolean;
|
||||
onCloseDetailPanel?: DesktopWorkspaceProps['onCloseDetailPanel'];
|
||||
documentLookup: Map<string, DeskDocument>;
|
||||
resolveBaseMetrics: (doc: DeskDocument | null | undefined, cardWidth: number, cardHeight: number) => {
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
baseScale: number;
|
||||
};
|
||||
bringToFront: (docId: Identifier | null | undefined) => void;
|
||||
setDraggingId: (value: string | null) => void;
|
||||
canvasSize: { width: number; height: number };
|
||||
openOverlayForDoc: (docId: Identifier | null | undefined, originInfo?: OverlayOriginTransform | null) => void;
|
||||
recalcVisibleDocIds: () => void;
|
||||
dragSettings: DragSettings;
|
||||
onInspectDocument?: DesktopWorkspaceProps['onInspectDocument'];
|
||||
markLayoutDirty: () => void;
|
||||
}
|
||||
|
||||
const DEBUG_DRAG = false;
|
||||
const DEBUG_FOCUS = false;
|
||||
const DesktopWorkspace = ({
|
||||
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
|
||||
documents = [],
|
||||
searchResults = null,
|
||||
onDocumentOpen,
|
||||
@@ -50,28 +209,35 @@ const DesktopWorkspace = ({
|
||||
tenantId = null,
|
||||
viewId = 'default',
|
||||
}) => {
|
||||
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
|
||||
const items = useMemo<DeskDocument[]>(
|
||||
() => (searchResults ? searchResults : documents),
|
||||
[documents, searchResults],
|
||||
);
|
||||
|
||||
const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:'));
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const itemRefs = useRef(new Map());
|
||||
const dragTransformsRef = useRef(new Map());
|
||||
const [overlayDocId, setOverlayDocId] = useState(null);
|
||||
const [overlayOriginRect, setOverlayOriginRect] = useState(null);
|
||||
const [overlayOriginTransform, setOverlayOriginTransform] = useState(null);
|
||||
const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map());
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
const dragTransformsRef = useRef<Map<string, DragTransformOverride>>(new Map());
|
||||
const [overlayDocId, setOverlayDocId] = useState<string | null>(null);
|
||||
const [overlayOriginRect, setOverlayOriginRect] = useState<DOMRect | null>(null);
|
||||
const [overlayOriginTransform, setOverlayOriginTransform] = useState<OverlayOriginTransform | null>(
|
||||
null,
|
||||
);
|
||||
const [previewSnapshots, setPreviewSnapshots] = useState<Map<string, NavigatorSnapshot>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const [docSizeVersion, setDocSizeVersion] = useState(0);
|
||||
const docSizeMapRef = useRef(new Map());
|
||||
const ensureDocumentSize = useCallback((doc) => {
|
||||
const docSizeMapRef = useRef<Map<string, DocumentSizeInfo>>(new Map());
|
||||
const ensureDocumentSize = useCallback((doc: DeskDocument | null | undefined): DocumentSizeInfo | null => {
|
||||
if (!doc?.id) {
|
||||
return null;
|
||||
}
|
||||
return docSizeMapRef.current.get(String(doc.id)) || null;
|
||||
}, []);
|
||||
const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl);
|
||||
const documentLookup = useMemo(() => {
|
||||
const map = new Map();
|
||||
const documentLookup = useMemo<Map<string, DeskDocument>>(() => {
|
||||
const map = new Map<string, DeskDocument>();
|
||||
items.forEach((doc) => {
|
||||
const key = doc?.id != null ? String(doc.id) : null;
|
||||
if (key) {
|
||||
@@ -81,7 +247,7 @@ const DesktopWorkspace = ({
|
||||
return map;
|
||||
}, [items]);
|
||||
|
||||
const engineRef = useRef(null);
|
||||
const engineRef = useRef<WorkspaceEngine | null>(null);
|
||||
if (!engineRef.current) {
|
||||
engineRef.current = new WorkspaceEngine({
|
||||
allowLayoutPersistence,
|
||||
@@ -89,7 +255,7 @@ const DesktopWorkspace = ({
|
||||
viewId,
|
||||
});
|
||||
}
|
||||
const engine = engineRef.current;
|
||||
const engine = engineRef.current as WorkspaceEngine;
|
||||
|
||||
useEffect(() => {
|
||||
engine.updateConfig({ allowLayoutPersistence, tenantId, viewId });
|
||||
@@ -115,7 +281,7 @@ const DesktopWorkspace = ({
|
||||
engine.setEnsureDocumentSize(ensureDocumentSize);
|
||||
}, [engine, ensureDocumentSize]);
|
||||
|
||||
const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore);
|
||||
const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore) as WorkspaceSnapshotState;
|
||||
const {
|
||||
layout: layoutSnapshot,
|
||||
canvasSize,
|
||||
@@ -141,10 +307,10 @@ const DesktopWorkspace = ({
|
||||
engine.setItemRefs(itemRefs);
|
||||
}, [engine, itemRefs]);
|
||||
|
||||
const layoutRef = useRef(layoutSnapshot);
|
||||
layoutRef.current = engine.layout;
|
||||
const layoutRef = useRef<Map<string, LayoutEntry>>(layoutSnapshot);
|
||||
layoutRef.current = engine.layout as Map<string, LayoutEntry>;
|
||||
|
||||
const bringToFront = useCallback((docId) => {
|
||||
const bringToFront = useCallback((docId: Identifier | null | undefined) => {
|
||||
engine.bringToFront(docId);
|
||||
}, [engine]);
|
||||
|
||||
@@ -156,11 +322,11 @@ const DesktopWorkspace = ({
|
||||
engine.recalcVisibleDocIds();
|
||||
}, [engine]);
|
||||
|
||||
const setDraggingId = useCallback((value) => {
|
||||
const setDraggingId = useCallback((value: string | number | null) => {
|
||||
engine.setDraggingId(value);
|
||||
}, [engine]);
|
||||
|
||||
const applySnapshotDimensions = useCallback((docKey, snapshot) => {
|
||||
const applySnapshotDimensions = useCallback((docKey: string, snapshot: NavigatorSnapshot | null) => {
|
||||
const width = Number(snapshot?.width);
|
||||
const height = Number(snapshot?.height);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
@@ -180,7 +346,7 @@ const DesktopWorkspace = ({
|
||||
setDocSizeVersion((value) => value + 1);
|
||||
}, []);
|
||||
const handleNavigatorSnapshot = useCallback(
|
||||
(docId, snapshot) => {
|
||||
(docId: Identifier | null, snapshot: NavigatorSnapshot | null) => {
|
||||
const docKey = docId != null ? String(docId) : null;
|
||||
if (!docKey) {
|
||||
return;
|
||||
@@ -222,11 +388,11 @@ const DesktopWorkspace = ({
|
||||
},
|
||||
[applySnapshotDimensions],
|
||||
);
|
||||
const activeTagSet = useMemo(() => {
|
||||
const activeTagSet = useMemo<Set<string>>(() => {
|
||||
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
const set = new Set();
|
||||
const set = new Set<string>();
|
||||
activeTagIds.forEach((id) => {
|
||||
if (id != null) {
|
||||
set.add(String(id));
|
||||
@@ -251,20 +417,20 @@ const DesktopWorkspace = ({
|
||||
|
||||
commitSize();
|
||||
|
||||
if (!('ResizeObserver' in window)) {
|
||||
if (typeof window.ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', commitSize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', commitSize);
|
||||
};
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(commitSize);
|
||||
const observer = new window.ResizeObserver(commitSize);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [engine]);
|
||||
|
||||
const resolvePreviewDimensions = useCallback(
|
||||
(doc) => {
|
||||
(doc: DeskDocument | null | undefined): PreviewMetadataEntry | null => {
|
||||
if (!doc?.id) {
|
||||
return null;
|
||||
}
|
||||
@@ -299,7 +465,7 @@ const DesktopWorkspace = ({
|
||||
const focusTarget = () => {
|
||||
try {
|
||||
canvas.focus({ preventScroll: true });
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
if (DEBUG_FOCUS) {
|
||||
void error;
|
||||
}
|
||||
@@ -386,7 +552,7 @@ const DesktopWorkspace = ({
|
||||
}
|
||||
}, [items, previewMetadata]);
|
||||
|
||||
const overlayDisplay = useMemo(() => {
|
||||
const overlayDisplay = useMemo<OverlayDisplay | null>(() => {
|
||||
if (!overlayDocId) {
|
||||
return null;
|
||||
}
|
||||
@@ -395,7 +561,7 @@ const DesktopWorkspace = ({
|
||||
return null;
|
||||
}
|
||||
const doc = documentLookup.get(overlayDocId);
|
||||
const alt = snapshot.alt || doc?.title;
|
||||
const alt = snapshot.alt || (doc?.title as string | undefined);
|
||||
return {
|
||||
url: snapshot.url,
|
||||
alt,
|
||||
@@ -421,7 +587,7 @@ const DesktopWorkspace = ({
|
||||
}, [overlayDocId, documentLookup]);
|
||||
|
||||
const resolveBaseMetrics = useCallback(
|
||||
(doc, cardWidth, cardHeight) => {
|
||||
(doc: DeskDocument | null | undefined, cardWidth: number, cardHeight: number) => {
|
||||
const previewDims = doc ? resolvePreviewDimensions(doc) : null;
|
||||
if (previewDims?.width && previewDims?.height) {
|
||||
const baseWidth = Math.max(previewDims.width, cardWidth);
|
||||
@@ -452,7 +618,7 @@ const DesktopWorkspace = ({
|
||||
}, [draggingId, items, setDraggingId]);
|
||||
|
||||
const openOverlayForDoc = useCallback(
|
||||
(docId, originInfo = null) => {
|
||||
(docId: Identifier | null | undefined, originInfo: OverlayOriginTransform | null = null) => {
|
||||
if (!docId) {
|
||||
return;
|
||||
}
|
||||
@@ -462,7 +628,9 @@ const DesktopWorkspace = ({
|
||||
return;
|
||||
}
|
||||
const container = itemRefs.current.get(docKey);
|
||||
const imageNode = container?.querySelector?.('.desk-item__card img');
|
||||
const imageNode = container
|
||||
? container.querySelector<HTMLImageElement>('.desk-item__card img')
|
||||
: null;
|
||||
if (!container || !imageNode) {
|
||||
return;
|
||||
}
|
||||
@@ -514,7 +682,7 @@ const DesktopWorkspace = ({
|
||||
],
|
||||
);
|
||||
|
||||
const dragSettings = useMemo(
|
||||
const dragSettings = useMemo<DragSettings>(
|
||||
() => ({
|
||||
canvasPadding: DESK_CANVAS_PADDING,
|
||||
defaultCanvasWidth: DESK_DEFAULT_CANVAS_WIDTH,
|
||||
@@ -524,7 +692,7 @@ const DesktopWorkspace = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const viewProps = useMemo(
|
||||
const viewProps = useMemo<DesktopWorkspaceViewProps>(
|
||||
() => ({
|
||||
engine,
|
||||
items,
|
||||
@@ -633,7 +801,7 @@ const DesktopWorkspace = ({
|
||||
return <DesktopWorkspaceView {...viewProps} />;
|
||||
};
|
||||
|
||||
const DesktopWorkspaceView = ({
|
||||
function DesktopWorkspaceView({
|
||||
engine,
|
||||
items,
|
||||
containerRef,
|
||||
@@ -684,7 +852,7 @@ const DesktopWorkspaceView = ({
|
||||
onInspectDocument,
|
||||
markLayoutDirty,
|
||||
dragTransformsRef,
|
||||
}) => {
|
||||
}: DesktopWorkspaceViewProps) {
|
||||
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
|
||||
useDocumentDrag({
|
||||
engine,
|
||||
@@ -705,7 +873,12 @@ const DesktopWorkspaceView = ({
|
||||
onDocumentStackSelect,
|
||||
selectedDocumentIds,
|
||||
markLayoutDirty,
|
||||
});
|
||||
}) as {
|
||||
handlePointerDown: React.PointerEventHandler<HTMLElement>;
|
||||
handlePointerMove: React.PointerEventHandler<HTMLElement>;
|
||||
handlePointerUp: React.PointerEventHandler<HTMLElement>;
|
||||
handlePointerCancel: React.PointerEventHandler<HTMLElement>;
|
||||
};
|
||||
|
||||
const { getCardPointerHandlers, handleShellKeyDown, focusShell } = useDeskPointer({
|
||||
containerRef,
|
||||
@@ -725,7 +898,11 @@ const DesktopWorkspaceView = ({
|
||||
detailPanelOpen,
|
||||
onCloseDetailPanel,
|
||||
openOverlayForDoc,
|
||||
});
|
||||
}) as {
|
||||
getCardPointerHandlers: (doc: DeskDocument) => React.HTMLAttributes<HTMLDivElement>;
|
||||
handleShellKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
|
||||
focusShell: () => void;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
focusShell();
|
||||
@@ -745,7 +922,7 @@ const DesktopWorkspaceView = ({
|
||||
|
||||
|
||||
|
||||
const allSizesReady = items.every((doc) => ensureDocumentSize(doc));
|
||||
const allSizesReady = items.every((doc) => Boolean(ensureDocumentSize(doc)));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -780,7 +957,7 @@ const DesktopWorkspaceView = ({
|
||||
<p>No documents to show here yet. Drop files to make this space come alive.</p>
|
||||
</div>
|
||||
) : (
|
||||
items.map((doc) => {
|
||||
items.map((doc, index) => {
|
||||
const sizeInfo = ensureDocumentSize(doc);
|
||||
if (!sizeInfo) {
|
||||
return null;
|
||||
@@ -797,10 +974,12 @@ const DesktopWorkspaceView = ({
|
||||
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
|
||||
return null;
|
||||
}
|
||||
const resolvedCenterX = centerX as number;
|
||||
const resolvedCenterY = centerY as number;
|
||||
const rotation = dragOverride?.rotation ?? layout?.rotation ?? 0;
|
||||
const scale = dragOverride?.scale ?? 1;
|
||||
const originX = centerX - cardWidth / 2;
|
||||
const originY = centerY - cardHeight / 2;
|
||||
const originX = resolvedCenterX - cardWidth / 2;
|
||||
const originY = resolvedCenterY - cardHeight / 2;
|
||||
const transform = formatTransform(
|
||||
Math.round(originX),
|
||||
Math.round(originY),
|
||||
@@ -816,16 +995,19 @@ const DesktopWorkspaceView = ({
|
||||
const shouldLoad = docKey ? visibleDocIds.has(docKey) : false;
|
||||
const dragging = docKey ? draggingId === docKey : false;
|
||||
const docTagKeys = Array.isArray(doc?.tags)
|
||||
? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean)
|
||||
? doc.tags
|
||||
.map((tag) => (tag?.id != null ? String(tag.id) : null))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
: [];
|
||||
const matchesFilter =
|
||||
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
|
||||
const dropActive = docKey ? tagDropTargetId === docKey : false;
|
||||
const dropPending = docKey ? pendingTagDocId === docKey : false;
|
||||
const isSelected = selectedDocumentIds.includes(doc.id);
|
||||
const docId = doc?.id ?? null;
|
||||
const isSelected = docId != null ? selectedDocumentIds.includes(docId) : false;
|
||||
const docTagTokens = docTagKeys.join(' ');
|
||||
const cardPointerHandlers = getCardPointerHandlers(doc);
|
||||
const registerNode = (node) => {
|
||||
const cardPointerHandlers = getCardPointerHandlers(doc) as React.HTMLAttributes<HTMLDivElement>;
|
||||
const registerNode = (node: HTMLDivElement | null) => {
|
||||
if (!docKey) {
|
||||
return;
|
||||
}
|
||||
@@ -838,7 +1020,7 @@ const DesktopWorkspaceView = ({
|
||||
|
||||
return (
|
||||
<DesktopDocumentCard
|
||||
key={doc.id}
|
||||
key={docKey ?? `desk-doc-${index}`}
|
||||
doc={doc}
|
||||
style={style}
|
||||
shouldLoad={shouldLoad}
|
||||
@@ -878,6 +1060,6 @@ const DesktopWorkspaceView = ({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default DesktopWorkspace;
|
||||
+26
-2
@@ -1,10 +1,34 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import SelectionFloatingActions from '../documents/SelectionFloatingActions';
|
||||
import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel';
|
||||
import createWorkspaceSurfaceConfig from '../documents/workspaceHeader';
|
||||
import DocumentViewerPanel from '../preview/DocumentViewerPanel';
|
||||
import DesktopWorkspace from './DesktopWorkspace';
|
||||
|
||||
type WorkspaceProps = Record<string, any>;
|
||||
|
||||
interface Breadcrumb {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface DetailProps extends Record<string, any> {
|
||||
onClose?: () => void;
|
||||
onOpenPreview?: () => void;
|
||||
tags?: unknown;
|
||||
}
|
||||
|
||||
interface CreateDesktopSurfaceArgs {
|
||||
workspaceProps?: WorkspaceProps | null;
|
||||
renderSidebarToggle?: () => ReactNode;
|
||||
parentBreadcrumb?: Breadcrumb | null;
|
||||
onNavigateParent?: () => void;
|
||||
detailProps?: DetailProps | null;
|
||||
detailOpen?: boolean;
|
||||
}
|
||||
|
||||
const createDesktopSurface = ({
|
||||
workspaceProps,
|
||||
renderSidebarToggle,
|
||||
@@ -12,7 +36,7 @@ const createDesktopSurface = ({
|
||||
onNavigateParent,
|
||||
detailProps = null,
|
||||
detailOpen = false,
|
||||
}) => {
|
||||
}: CreateDesktopSurfaceArgs) => {
|
||||
if (!workspaceProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -2,15 +2,15 @@ const DB_NAME = 'papercrate_desk';
|
||||
const DB_VERSION = 1;
|
||||
const LAYOUT_STORE = 'layouts';
|
||||
|
||||
const currentDbPromise = { value: null };
|
||||
const currentDbPromise: { value: Promise<IDBDatabase | null> | null } = { value: null };
|
||||
|
||||
const openDatabase = () => {
|
||||
const openDatabase = (): Promise<IDBDatabase> => {
|
||||
if (currentDbPromise.value) {
|
||||
return currentDbPromise.value;
|
||||
return currentDbPromise.value as Promise<IDBDatabase>;
|
||||
}
|
||||
|
||||
currentDbPromise.value = new Promise((resolve, reject) => {
|
||||
const dbApi = window.indexedDB;
|
||||
const dbApi = typeof window !== 'undefined' ? window.indexedDB : null;
|
||||
if (!dbApi) {
|
||||
reject(new Error('IndexedDB not available'));
|
||||
return;
|
||||
@@ -39,51 +39,56 @@ const openDatabase = () => {
|
||||
};
|
||||
});
|
||||
|
||||
return currentDbPromise.value;
|
||||
return currentDbPromise.value as Promise<IDBDatabase>;
|
||||
};
|
||||
|
||||
const requestToPromise = (request, defaultValue) => new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
const { result } = request;
|
||||
resolve(result ?? defaultValue);
|
||||
};
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error('IndexedDB request failed'));
|
||||
};
|
||||
});
|
||||
const requestToPromise = <T>(request: IDBRequest<T>, defaultValue: T): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
const { result } = request;
|
||||
resolve(result ?? defaultValue);
|
||||
};
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error('IndexedDB request failed'));
|
||||
};
|
||||
});
|
||||
|
||||
const iterateCursor = (request, iteratee) => new Promise((resolve, reject) => {
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (!cursor) {
|
||||
const iterateCursor = (request: IDBRequest<IDBCursorWithValue | null>, iteratee: (cursor: IDBCursorWithValue) => void) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
iteratee(cursor);
|
||||
cursor.continue();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error('IndexedDB cursor failed'));
|
||||
};
|
||||
});
|
||||
|
||||
const transactionComplete = (transaction: IDBTransaction) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
iteratee(cursor);
|
||||
cursor.continue();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
request.onerror = () => {
|
||||
reject(request.error || new Error('IndexedDB cursor failed'));
|
||||
};
|
||||
});
|
||||
};
|
||||
transaction.onerror = () => {
|
||||
reject(transaction.error || new Error('IndexedDB transaction failed'));
|
||||
};
|
||||
transaction.onabort = () => {
|
||||
reject(transaction.error || new Error('IndexedDB transaction aborted'));
|
||||
};
|
||||
});
|
||||
|
||||
const transactionComplete = (transaction) => new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => {
|
||||
resolve();
|
||||
};
|
||||
transaction.onerror = () => {
|
||||
reject(transaction.error || new Error('IndexedDB transaction failed'));
|
||||
};
|
||||
transaction.onabort = () => {
|
||||
reject(transaction.error || new Error('IndexedDB transaction aborted'));
|
||||
};
|
||||
});
|
||||
type TransactionMode = 'readonly' | 'readwrite' | 'versionchange';
|
||||
|
||||
const withStore = async (mode, handler) => {
|
||||
const withStore = async <T>(mode: TransactionMode, handler: (store: IDBObjectStore, tx: IDBTransaction) => Promise<T> | T): Promise<T> => {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(LAYOUT_STORE, mode);
|
||||
const store = transaction.objectStore(LAYOUT_STORE);
|
||||
@@ -101,13 +106,24 @@ const withStore = async (mode, handler) => {
|
||||
try {
|
||||
await done;
|
||||
} catch {
|
||||
// noop – prefer original error
|
||||
// ignore
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchLayoutRecords = async ({ tenantId, viewId }) => {
|
||||
interface LayoutRecord {
|
||||
tenantId: string | number;
|
||||
viewId: string | number;
|
||||
documentId: string | number;
|
||||
centerX?: number;
|
||||
centerY?: number;
|
||||
rotation?: number;
|
||||
zIndex?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export const fetchLayoutRecords = async ({ tenantId, viewId }: { tenantId?: string | number; viewId?: string | number }): Promise<LayoutRecord[]> => {
|
||||
if (!tenantId || !viewId) {
|
||||
return [];
|
||||
}
|
||||
@@ -123,7 +139,7 @@ export const fetchLayoutRecords = async ({ tenantId, viewId }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
|
||||
export const upsertLayoutRecords = async ({ tenantId, viewId, entries }: { tenantId?: string | number; viewId?: string | number; entries?: Array<{ documentId?: string | number; centerX?: number; centerY?: number; rotation?: number; zIndex?: number; updatedAt?: number }> }) => {
|
||||
if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) {
|
||||
return;
|
||||
}
|
||||
@@ -144,7 +160,7 @@ export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
|
||||
rotation: Number(entry.rotation) || 0,
|
||||
zIndex: Number(entry.zIndex) || 0,
|
||||
updatedAt: entry.updatedAt || timestamp,
|
||||
});
|
||||
} satisfies LayoutRecord);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -152,7 +168,7 @@ export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTenantLayouts = async (tenantId) => {
|
||||
export const deleteTenantLayouts = async (tenantId?: string | number) => {
|
||||
if (!tenantId) {
|
||||
return;
|
||||
}
|
||||
@@ -169,15 +185,17 @@ export const deleteTenantLayouts = async (tenantId) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const closeDeskDatabase = () => {
|
||||
export const closeDeskDatabase = (): void => {
|
||||
if (!currentDbPromise.value) {
|
||||
return;
|
||||
}
|
||||
currentDbPromise.value = currentDbPromise.value.then((db) => {
|
||||
try {
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to close IndexedDB', error);
|
||||
if (db) {
|
||||
try {
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.warn('[desk] Failed to close IndexedDB', error);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
export const preventAll = (event) => {
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
type PointerLikeEvent = MouseEvent & { pageX?: number; pageY?: number };
|
||||
type PreventableEvent = Event | ReactPointerEvent | { preventDefault?: () => void; stopPropagation?: () => void };
|
||||
|
||||
export const preventAll = (event?: PreventableEvent | null): void => {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
@@ -14,9 +19,15 @@ export const preventAll = (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const safeInvoke = (fn, ...args) => fn?.(...args);
|
||||
type AnyFn = (...args: unknown[]) => unknown;
|
||||
|
||||
export const getPointerPosition = (event, { fallbackToPage = true } = {}) => {
|
||||
export const safeInvoke = <Fn extends AnyFn>(fn: Fn | null | undefined, ...args: Parameters<Fn>): ReturnType<Fn> | undefined =>
|
||||
(fn ? fn(...args) : undefined);
|
||||
|
||||
export const getPointerPosition = (
|
||||
event?: PointerLikeEvent | null,
|
||||
{ fallbackToPage = true }: { fallbackToPage?: boolean } = {},
|
||||
): { x: number; y: number } => {
|
||||
if (!event) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
+29
-5
@@ -1,8 +1,32 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createAssetView } from '../../asset_manager';
|
||||
|
||||
const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
|
||||
const [metadataMap, setMetadataMap] = useState(() => new Map());
|
||||
interface DocumentLike {
|
||||
id?: string | number;
|
||||
current_version?: unknown;
|
||||
tags?: unknown;
|
||||
}
|
||||
|
||||
interface AssetLike {
|
||||
id?: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface PreviewMetadataEntry {
|
||||
docId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type GetDocumentAsset = (doc: DocumentLike, type: string) => AssetLike | null | undefined;
|
||||
type EnsureAssetUrl = (docId: string | number, asset: AssetLike, options?: { start?: number; limit?: number }) => Promise<AssetLike | null | undefined>;
|
||||
|
||||
const usePreviewMetadata = (
|
||||
documents: DocumentLike[] | null | undefined,
|
||||
getDocumentAsset?: GetDocumentAsset,
|
||||
ensureAssetUrl?: EnsureAssetUrl,
|
||||
) => {
|
||||
const [metadataMap, setMetadataMap] = useState<Map<string, PreviewMetadataEntry>>(() => new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -14,19 +38,19 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
|
||||
};
|
||||
}
|
||||
|
||||
const fetchMetadataForDoc = async (doc) => {
|
||||
const fetchMetadataForDoc = async (doc: DocumentLike) => {
|
||||
if (!doc?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const docId = String(doc.id);
|
||||
const resolveAsset = (type) => getDocumentAsset?.(doc, type) ?? null;
|
||||
const resolveAsset = (type: string) => getDocumentAsset?.(doc, type) ?? null;
|
||||
|
||||
let asset = resolveAsset('preview') || resolveAsset('thumbnail');
|
||||
let view = createAssetView(asset);
|
||||
let metadata = view.getPrimaryMetadata();
|
||||
|
||||
const hasDimensions = (meta) =>
|
||||
const hasDimensions = (meta: { width?: number | string; height?: number | string } | null | undefined) =>
|
||||
Number.isFinite(Number(meta?.width)) &&
|
||||
Number.isFinite(Number(meta?.height)) &&
|
||||
Number(meta.width) > 0 &&
|
||||
@@ -1,4 +0,0 @@
|
||||
export { clamp } from '../utils/math';
|
||||
|
||||
export const formatTransform = (x, y, rotation = 0, scale = 1) =>
|
||||
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
||||
@@ -0,0 +1,8 @@
|
||||
export { clamp } from '../utils/math';
|
||||
|
||||
export const formatTransform = (
|
||||
x: number,
|
||||
y: number,
|
||||
rotation = 0,
|
||||
scale = 1,
|
||||
): string => `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
||||
+55
-8
@@ -1,4 +1,4 @@
|
||||
import { safeInvoke } from '../events.js';
|
||||
import { safeInvoke } from '../events';
|
||||
|
||||
export const CLICK_ACTIONS = {
|
||||
selectSingle: 'selectSingle',
|
||||
@@ -6,20 +6,52 @@ export const CLICK_ACTIONS = {
|
||||
addCard: 'addCard',
|
||||
addStack: 'addStack',
|
||||
none: 'none',
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const DRAG_ACTIONS = {
|
||||
dragSelectSingle: 'dragSelectSingle',
|
||||
dragSelection: 'dragSelection',
|
||||
dragSelectStack: 'dragSelectStack',
|
||||
none: 'none',
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type ClickAction = (typeof CLICK_ACTIONS)[keyof typeof CLICK_ACTIONS];
|
||||
export type DragAction = (typeof DRAG_ACTIONS)[keyof typeof DRAG_ACTIONS];
|
||||
|
||||
export const STACK_HIT_EPSILON = 4;
|
||||
export const POINTER_DRAG_THRESHOLD_SQUARED = 16;
|
||||
export const LONG_PRESS_DURATION_MS = 450;
|
||||
|
||||
export const withinThreshold = (dx, dy, thresholdSquared) => (dx * dx + dy * dy) <= thresholdSquared;
|
||||
export const withinThreshold = (dx: number, dy: number, thresholdSquared: number): boolean => (dx * dx + dy * dy) <= thresholdSquared;
|
||||
|
||||
interface PointerIntentArgs {
|
||||
doc: { id: string | number };
|
||||
entryDescriptor: unknown;
|
||||
selectedDocumentIds: Array<string | number>;
|
||||
metaKey: boolean;
|
||||
pointerButton?: number;
|
||||
pointerType?: string;
|
||||
stackHits?: string[] | null;
|
||||
}
|
||||
|
||||
export interface PointerIntent {
|
||||
docId: string | number;
|
||||
entryDescriptor: unknown;
|
||||
pointerType?: string;
|
||||
pointerButton?: number;
|
||||
selectedAtDown: boolean;
|
||||
selectionCountAtDown: number;
|
||||
metaKey: boolean;
|
||||
clickAction: ClickAction;
|
||||
dragAction: DragAction;
|
||||
stackDocIdsForDrag: string[] | null;
|
||||
stackDocIdsForClick: string[] | null;
|
||||
stackReplaceOnClick: boolean;
|
||||
stackReplaceOnDrag: boolean;
|
||||
clickSelectionApplied: boolean;
|
||||
stackSelectionApplied: boolean;
|
||||
longPressTriggered: boolean;
|
||||
}
|
||||
|
||||
export const createPointerIntent = ({
|
||||
doc,
|
||||
@@ -29,7 +61,7 @@ export const createPointerIntent = ({
|
||||
pointerButton,
|
||||
pointerType,
|
||||
stackHits,
|
||||
}) => {
|
||||
}: PointerIntentArgs): PointerIntent => {
|
||||
const alreadySelected = selectedDocumentIds.includes(doc.id);
|
||||
const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
|
||||
|
||||
@@ -74,7 +106,12 @@ export const createPointerIntent = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => {
|
||||
export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }: {
|
||||
intent: PointerIntent;
|
||||
event?: unknown;
|
||||
onEntryPointer?: (descriptor: unknown, event?: unknown) => void;
|
||||
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
|
||||
}) => {
|
||||
switch (intent.clickAction) {
|
||||
case CLICK_ACTIONS.selectSingle:
|
||||
case CLICK_ACTIONS.addCard:
|
||||
@@ -100,7 +137,12 @@ export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDoc
|
||||
}
|
||||
};
|
||||
|
||||
export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => {
|
||||
export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }: {
|
||||
intent: PointerIntent;
|
||||
event?: unknown;
|
||||
onEntryPointer?: (descriptor: unknown, event?: unknown) => void;
|
||||
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
|
||||
}) => {
|
||||
if (!intent || intent.clickSelectionApplied) {
|
||||
return;
|
||||
}
|
||||
@@ -108,7 +150,12 @@ export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocume
|
||||
applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect });
|
||||
};
|
||||
|
||||
export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }) => {
|
||||
export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }: {
|
||||
intent: PointerIntent;
|
||||
stackDocIds?: string[] | null;
|
||||
syntheticEvent?: unknown;
|
||||
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
|
||||
}) => {
|
||||
if (!intent) {
|
||||
return;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
finalizeClickSelection,
|
||||
withinThreshold,
|
||||
} from './pointerUtils';
|
||||
import { getPointerPosition, safeInvoke } from '../events.js';
|
||||
import { getPointerPosition, safeInvoke } from '../events';
|
||||
|
||||
const buildEntryDescriptor = (docId) => ({
|
||||
type: 'document',
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { getPointerPosition, preventAll, safeInvoke } from '../events.js';
|
||||
import { getPointerPosition, preventAll, safeInvoke } from '../events';
|
||||
import {
|
||||
isTagTransferEvent,
|
||||
parseTagTransferPayload,
|
||||
|
||||
+79
-15
@@ -1,11 +1,75 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
type DocumentEntry = { id?: Identifier } & Record<string, unknown>;
|
||||
|
||||
type WorkspaceViewMode = 'desk' | 'grid' | 'list' | string;
|
||||
|
||||
type DeskDocumentStackSelectHandler = (docIds: Identifier[]) => void;
|
||||
|
||||
type ResolveRowKey = (id: Identifier) => Identifier | string | null;
|
||||
|
||||
type ApplySelection = (
|
||||
keys: Array<Identifier | string>,
|
||||
options: { anchor?: Identifier | string | null; interactedKeys?: Array<Identifier | string> },
|
||||
) => void;
|
||||
|
||||
interface UseDeskWorkspacePropsArgs {
|
||||
documents?: DocumentEntry[];
|
||||
searchResults?: DocumentEntry[];
|
||||
breadcrumbs?: unknown[];
|
||||
currentFolderName?: string | null;
|
||||
documentsViewMode?: WorkspaceViewMode;
|
||||
handleDocumentsViewModeChange?: (mode: WorkspaceViewMode) => void;
|
||||
handleDeskExit?: () => void;
|
||||
refreshCurrentFolder?: () => Promise<void> | void;
|
||||
inspectDocument?: (doc: DocumentEntry) => void;
|
||||
handleEntryPointerCore?: (...args: unknown[]) => void;
|
||||
promoteSelectionOrder?: (...args: unknown[]) => void;
|
||||
currentTenantId?: Identifier | null;
|
||||
selectedDocumentIds?: Identifier[];
|
||||
selectedFolderIds?: Identifier[];
|
||||
clearDocumentSelection?: () => void;
|
||||
detailPanelOpen?: boolean;
|
||||
handleDetailPanelClose?: () => void;
|
||||
resolveThumbnailUrlForDoc?: (doc: DocumentEntry) => string | null;
|
||||
handleDocumentTagDrop?: (...args: unknown[]) => void;
|
||||
handleTagRemove?: (...args: unknown[]) => void;
|
||||
ensureAssetUrl?: (...args: unknown[]) => void;
|
||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||
activeTagFilters?: Identifier[];
|
||||
handleDeleteSelection?: () => void;
|
||||
tags?: unknown[];
|
||||
correspondents?: unknown[];
|
||||
documentLookup?: unknown;
|
||||
tagLookupById?: unknown;
|
||||
handleBulkTagAddFromDetail?: (...args: unknown[]) => void;
|
||||
handleBulkTagRemoveFromDetail?: (...args: unknown[]) => void;
|
||||
handleBulkCorrespondentAdd?: (...args: unknown[]) => void;
|
||||
handleBulkCorrespondentRemove?: (...args: unknown[]) => void;
|
||||
handleBulkSelectionReanalyze?: (...args: unknown[]) => void;
|
||||
folderOptions?: unknown[];
|
||||
moveDocumentsToFolder?: (...args: unknown[]) => void;
|
||||
searchIncludeDescendants?: boolean;
|
||||
toggleSearchIncludeDescendants?: () => void;
|
||||
selectedEntries?: Array<Identifier | string>;
|
||||
selectionAnchorRef: { current: Identifier | string | null };
|
||||
applySelection: ApplySelection;
|
||||
resolveDocumentRowKey: ResolveRowKey;
|
||||
showingSearchResults?: boolean;
|
||||
searchQuery?: string;
|
||||
activeCorrespondentFilters?: Identifier[];
|
||||
selectedFolder?: Identifier | string | null;
|
||||
openDetailPanel?: (args: { documentIds: Identifier[] }) => void;
|
||||
}
|
||||
|
||||
const useDeskWorkspaceProps = ({
|
||||
documents,
|
||||
searchResults,
|
||||
breadcrumbs,
|
||||
currentFolderName,
|
||||
documentsViewMode,
|
||||
documentsViewMode = 'desk',
|
||||
handleDocumentsViewModeChange,
|
||||
handleDeskExit,
|
||||
refreshCurrentFolder,
|
||||
@@ -23,7 +87,7 @@ const useDeskWorkspaceProps = ({
|
||||
handleTagRemove,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
activeTagFilters,
|
||||
activeTagFilters = [],
|
||||
handleDeleteSelection,
|
||||
tags,
|
||||
correspondents,
|
||||
@@ -38,17 +102,17 @@ const useDeskWorkspaceProps = ({
|
||||
moveDocumentsToFolder,
|
||||
searchIncludeDescendants,
|
||||
toggleSearchIncludeDescendants,
|
||||
selectedEntries,
|
||||
selectedEntries = [],
|
||||
selectionAnchorRef,
|
||||
applySelection,
|
||||
resolveDocumentRowKey,
|
||||
showingSearchResults,
|
||||
searchQuery,
|
||||
activeCorrespondentFilters,
|
||||
showingSearchResults = false,
|
||||
searchQuery = '',
|
||||
activeCorrespondentFilters = [],
|
||||
selectedFolder,
|
||||
openDetailPanel,
|
||||
}) => {
|
||||
const handleDeskDocumentStackSelect = useCallback(
|
||||
}: UseDeskWorkspacePropsArgs) => {
|
||||
const handleDeskDocumentStackSelect: DeskDocumentStackSelectHandler = useCallback(
|
||||
(docIds) => {
|
||||
if (!Array.isArray(docIds) || docIds.length === 0) {
|
||||
return;
|
||||
@@ -56,7 +120,7 @@ const useDeskWorkspaceProps = ({
|
||||
|
||||
const rowKeys = docIds
|
||||
.map((id) => resolveDocumentRowKey(id))
|
||||
.filter(Boolean);
|
||||
.filter((value): value is Identifier | string => Boolean(value));
|
||||
|
||||
if (!rowKeys.length) {
|
||||
return;
|
||||
@@ -69,9 +133,9 @@ const useDeskWorkspaceProps = ({
|
||||
}
|
||||
});
|
||||
|
||||
const anchor = rowKeys[0]
|
||||
const anchor = (rowKeys[0]
|
||||
|| selectionAnchorRef.current
|
||||
|| nextKeys[nextKeys.length - 1];
|
||||
|| nextKeys[nextKeys.length - 1]) as Identifier | string | null;
|
||||
|
||||
applySelection(nextKeys, {
|
||||
anchor,
|
||||
@@ -82,13 +146,13 @@ const useDeskWorkspaceProps = ({
|
||||
);
|
||||
|
||||
const handleDeskDocumentOpen = useCallback(
|
||||
(docId, { useSelection = false } = {}) => {
|
||||
(docId: Identifier | undefined, { useSelection = false }: { useSelection?: boolean } = {}) => {
|
||||
const selectionDocIds = Array.isArray(selectedDocumentIds)
|
||||
? selectedDocumentIds
|
||||
: [];
|
||||
let targetIds = [];
|
||||
let targetIds: Identifier[] = [];
|
||||
|
||||
if ((useSelection || selectionDocIds.includes(docId)) && selectionDocIds.length) {
|
||||
if ((useSelection || selectionDocIds.includes(docId as Identifier)) && selectionDocIds.length) {
|
||||
targetIds = selectionDocIds;
|
||||
} else if (selectionDocIds.length) {
|
||||
targetIds = selectionDocIds;
|
||||
@@ -100,7 +164,7 @@ const useDeskWorkspaceProps = ({
|
||||
return;
|
||||
}
|
||||
|
||||
openDetailPanel({ documentIds: targetIds });
|
||||
openDetailPanel?.({ documentIds: targetIds });
|
||||
},
|
||||
[openDetailPanel, selectedDocumentIds],
|
||||
);
|
||||
+203
-36
@@ -1,26 +1,179 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MutableRefObject,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { preventAll, safeInvoke } from './events';
|
||||
import { clamp } from './math';
|
||||
import usePointerTap from '../ui/usePointerTap';
|
||||
import { MIN_TIMESTEP, MAX_TIMESTEP, applyDomTransform } from './workspaceEngine';
|
||||
import {
|
||||
MIN_TIMESTEP,
|
||||
MAX_TIMESTEP,
|
||||
applyDomTransform,
|
||||
type WorkspaceEngine,
|
||||
} from './workspaceEngine';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier | null;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DocumentSizeInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface LayoutEntry {
|
||||
centerX?: number;
|
||||
centerY?: number;
|
||||
rotation?: number;
|
||||
z?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface DragTransform {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
type EngineDragState = Parameters<WorkspaceEngine['finalizeGroupDrag']>[0];
|
||||
type EngineGroupItem = NonNullable<EngineDragState['groupItems']>[number];
|
||||
|
||||
interface DragGroupItemInternal extends EngineGroupItem {
|
||||
baseOffsetX?: number;
|
||||
baseOffsetY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
targetRotation?: number;
|
||||
}
|
||||
|
||||
type EnsureDocumentSizeFn = (doc: DocumentLike | null | undefined) => DocumentSizeInfo | null;
|
||||
|
||||
type ResolveBaseMetricsFn = (
|
||||
doc: DocumentLike | null | undefined,
|
||||
width: number,
|
||||
height: number,
|
||||
) => { baseWidth: number; baseHeight: number; baseScale: number };
|
||||
|
||||
interface DragSettings {
|
||||
canvasPadding?: number;
|
||||
defaultCanvasWidth?: number;
|
||||
defaultCanvasHeight?: number;
|
||||
debugDrag?: boolean;
|
||||
}
|
||||
|
||||
interface PointerDownOptions {
|
||||
stackDocIds?: Array<Identifier | null | undefined>;
|
||||
stackSelectionApplied?: boolean;
|
||||
wasSelected?: boolean;
|
||||
modifierActive?: boolean;
|
||||
stackReplace?: boolean;
|
||||
}
|
||||
|
||||
interface UseDocumentDragOptions {
|
||||
engine?: WorkspaceEngine | null;
|
||||
layoutRef: MutableRefObject<Map<string, LayoutEntry>>;
|
||||
dragTransformsRef: MutableRefObject<Map<string, DragTransform>>;
|
||||
itemRefs: MutableRefObject<Map<string, HTMLElement | null>>;
|
||||
documentLookup: Map<string, DocumentLike>;
|
||||
ensureDocumentSize: EnsureDocumentSizeFn;
|
||||
resolveBaseMetrics: ResolveBaseMetricsFn;
|
||||
bringToFront: (docId: Identifier | null | undefined) => void;
|
||||
setDraggingId: (docKey: string | null) => void;
|
||||
canvasSize: { width: number; height: number };
|
||||
openOverlayForDoc?: (
|
||||
docId: Identifier | null | undefined,
|
||||
originInfo?: { rotation: number; scale: number; width: number; height: number },
|
||||
) => void;
|
||||
recalcVisibleDocIds: () => void;
|
||||
settings?: DragSettings;
|
||||
containerRef?: RefObject<HTMLElement>;
|
||||
onInspectDocument?: (docId: Identifier | null | undefined, event?: PointerEvent | ReactPointerEvent) => void;
|
||||
onDocumentStackSelect?: (
|
||||
docIds: Identifier[],
|
||||
event: PointerEvent | ReactPointerEvent,
|
||||
options?: { replace?: boolean },
|
||||
) => void;
|
||||
selectedDocumentIds?: Array<Identifier | null | undefined>;
|
||||
markLayoutDirty?: () => void;
|
||||
}
|
||||
|
||||
type EngineInertiaState = Parameters<WorkspaceEngine['startInertiaAnimation']>[1];
|
||||
|
||||
interface DragStateInternal extends EngineDragState {
|
||||
docId: string;
|
||||
docKey: string;
|
||||
pointerId: number;
|
||||
originCenterX: number;
|
||||
originCenterY: number;
|
||||
currentCenterX: number;
|
||||
currentCenterY: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
rotation: number;
|
||||
restRotation: number;
|
||||
dynamicRotation: number;
|
||||
angularVelocity: number;
|
||||
moved: boolean;
|
||||
locked: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
dragScale: number;
|
||||
baseScale: number;
|
||||
capturedTarget: HTMLElement | null;
|
||||
lastClientX: number;
|
||||
lastClientY: number;
|
||||
lastTimestamp: number;
|
||||
localPointerOffsetX: number;
|
||||
localPointerOffsetY: number;
|
||||
containerRectLeft: number;
|
||||
containerRectTop: number;
|
||||
isGroup: boolean;
|
||||
activeDocIds: string[];
|
||||
groupItems: DragGroupItemInternal[];
|
||||
groupElevated: boolean;
|
||||
stackDocIds: string[] | null;
|
||||
stackSelectionApplied: boolean;
|
||||
stackReplace: boolean;
|
||||
}
|
||||
|
||||
type PointerEventLike = PointerEvent | ReactPointerEvent<HTMLElement>;
|
||||
|
||||
interface DragTapMetadata {
|
||||
docId: Identifier | null;
|
||||
originInfo?: { rotation: number; scale: number; width: number; height: number };
|
||||
docTitle: string;
|
||||
}
|
||||
|
||||
const DRAG_HYSTERESIS_PX = 4;
|
||||
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
||||
const EDGE_COLLISION_THRESHOLD = 0.5;
|
||||
|
||||
const getEventTargetElement = (event) => {
|
||||
const getEventTargetElement = (event?: PointerEventLike | null): Element | null => {
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
const ElementCtor = window.Element;
|
||||
const ElementCtor = typeof window !== 'undefined' ? window.Element : null;
|
||||
if (!ElementCtor) {
|
||||
return null;
|
||||
}
|
||||
const candidate = event.target || (event.nativeEvent ? event.nativeEvent.target : null);
|
||||
const nativeEvent = 'nativeEvent' in event ? (event as ReactPointerEvent).nativeEvent : null;
|
||||
const candidate = (event.target as Element | null) || (nativeEvent ? (nativeEvent.target as Element | null) : null);
|
||||
return candidate instanceof ElementCtor ? candidate : null;
|
||||
};
|
||||
|
||||
const useDocumentDrag = (options = {}) => {
|
||||
const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
||||
const {
|
||||
engine,
|
||||
layoutRef,
|
||||
@@ -35,13 +188,16 @@ const useDocumentDrag = (options = {}) => {
|
||||
openOverlayForDoc,
|
||||
recalcVisibleDocIds,
|
||||
settings,
|
||||
containerRef,
|
||||
containerRef: providedContainerRef,
|
||||
onInspectDocument,
|
||||
onDocumentStackSelect,
|
||||
selectedDocumentIds,
|
||||
selectedDocumentIds = [],
|
||||
markLayoutDirty,
|
||||
} = options;
|
||||
|
||||
const fallbackContainerRef = useRef<HTMLElement | null>(null);
|
||||
const containerRef = providedContainerRef ?? fallbackContainerRef;
|
||||
|
||||
const {
|
||||
canvasPadding = 24,
|
||||
defaultCanvasWidth = 1024,
|
||||
@@ -56,23 +212,23 @@ const useDocumentDrag = (options = {}) => {
|
||||
[engine],
|
||||
);
|
||||
|
||||
const tapHandler = usePointerTap({
|
||||
const tapHandler = usePointerTap<DragTapMetadata>({
|
||||
delay: 220,
|
||||
onSingle: () => {},
|
||||
onDouble: ({ data, event }) => {
|
||||
if (!data || !data.docId) {
|
||||
if (!data?.docId) {
|
||||
return;
|
||||
}
|
||||
if (event?.altKey) {
|
||||
openOverlayForDoc(data.docId, data.originInfo);
|
||||
openOverlayForDoc?.(data.docId, data.originInfo);
|
||||
return;
|
||||
}
|
||||
onInspectDocument?.(data.docId, event);
|
||||
},
|
||||
});
|
||||
const dragStateRef = useRef(null);
|
||||
const dragStateRef = useRef<DragStateInternal | null>(null);
|
||||
|
||||
const setDragTransform = useCallback((docKey, transform) => {
|
||||
const setDragTransform = useCallback((docKey: Identifier | null | undefined, transform: DragTransform | null) => {
|
||||
if (!docKey) {
|
||||
return;
|
||||
}
|
||||
@@ -80,7 +236,11 @@ const useDocumentDrag = (options = {}) => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
map.set(String(docKey), transform);
|
||||
if (transform) {
|
||||
map.set(String(docKey), transform);
|
||||
} else {
|
||||
map.delete(String(docKey));
|
||||
}
|
||||
}, [dragTransformsRef]);
|
||||
|
||||
const clearDragTransforms = useCallback(() => {
|
||||
@@ -91,13 +251,15 @@ const useDocumentDrag = (options = {}) => {
|
||||
map.clear();
|
||||
}, [dragTransformsRef]);
|
||||
|
||||
const commitActiveDragTransforms = useCallback((docIds = null) => {
|
||||
const commitActiveDragTransforms = useCallback((docIds: Array<Identifier | null | undefined> | null = null) => {
|
||||
const map = dragTransformsRef?.current;
|
||||
if (!map || !map.size) {
|
||||
return;
|
||||
}
|
||||
const keys = Array.isArray(docIds) && docIds.length
|
||||
? docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean)
|
||||
? docIds
|
||||
.map((id) => (id != null ? String(id) : null))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
: Array.from(map.keys());
|
||||
keys.forEach((key) => {
|
||||
const transform = map.get(key);
|
||||
@@ -116,7 +278,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
}, [dragTransformsRef, layoutRef, markLayoutDirty]);
|
||||
|
||||
const finishDrag = useCallback(
|
||||
(pointerId, { clearTransforms = true } = {}) => {
|
||||
(pointerId: number, { clearTransforms = true }: { clearTransforms?: boolean } = {}) => {
|
||||
const state = dragStateRef.current;
|
||||
if (state && state.pointerId === pointerId) {
|
||||
const capturedTarget = state.capturedTarget;
|
||||
@@ -141,7 +303,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event, docIdInput, options = {}) => {
|
||||
(event: PointerEventLike, docIdInput?: Identifier | null, options: PointerDownOptions = {}) => {
|
||||
const targetElement = getEventTargetElement(event);
|
||||
if (targetElement?.closest && targetElement.closest('[data-desk-tag-chip="true"]')) {
|
||||
return;
|
||||
@@ -165,7 +327,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw)
|
||||
? stackDocIdsOptionRaw
|
||||
.map((value) => (value != null ? String(value) : null))
|
||||
.filter(Boolean)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
: null;
|
||||
const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied);
|
||||
const wasSelectedAtPointerDown = Boolean(options?.wasSelected);
|
||||
@@ -173,8 +335,10 @@ const useDocumentDrag = (options = {}) => {
|
||||
options?.modifierActive ?? Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
|
||||
const stackReplace = Boolean(options?.stackReplace);
|
||||
|
||||
let selectionIds = Array.isArray(selectedDocumentIds)
|
||||
? selectedDocumentIds.map((id) => String(id))
|
||||
let selectionIds: string[] = Array.isArray(selectedDocumentIds)
|
||||
? selectedDocumentIds
|
||||
.map((id) => (id != null ? String(id) : null))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
: [];
|
||||
|
||||
if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) {
|
||||
@@ -196,9 +360,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
selectionIds = [...selectionIds, docKey];
|
||||
}
|
||||
|
||||
selectionIds = selectionIds
|
||||
.map((id) => String(id))
|
||||
.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
|
||||
selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
|
||||
|
||||
if (!selectionIds.includes(docKey)) {
|
||||
selectionIds.unshift(docKey);
|
||||
@@ -266,7 +428,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
const containerRect = containerRef?.current?.getBoundingClientRect?.() || null;
|
||||
const containerRect = containerRef.current?.getBoundingClientRect?.() || null;
|
||||
const containerLeft = containerRect?.left || 0;
|
||||
const containerTop = containerRect?.top || 0;
|
||||
const pointerCanvasX = event.clientX - containerLeft;
|
||||
@@ -280,7 +442,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
|
||||
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
|
||||
|
||||
const groupItems = selectionIds.map((id) => {
|
||||
const groupItems: DragGroupItemInternal[] = selectionIds.map((id) => {
|
||||
const itemDoc = documentLookup.get(id);
|
||||
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
|
||||
const itemWidth = itemSize.width || docWidth;
|
||||
@@ -307,7 +469,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
initialRotation,
|
||||
displayRotation: initialRotation,
|
||||
targetRotation,
|
||||
};
|
||||
} satisfies DragGroupItemInternal;
|
||||
});
|
||||
|
||||
const eventTimestamp =
|
||||
@@ -354,9 +516,12 @@ const useDocumentDrag = (options = {}) => {
|
||||
stackDocIds: hasStackSource ? stackDocIdsOption : null,
|
||||
stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource,
|
||||
stackReplace,
|
||||
};
|
||||
} satisfies DragStateInternal;
|
||||
|
||||
const state = dragStateRef.current;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearDragTransforms();
|
||||
state.groupItems.forEach((item) => {
|
||||
@@ -416,7 +581,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
]);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event) => {
|
||||
(event: PointerEventLike) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state) {
|
||||
return;
|
||||
@@ -427,7 +592,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
preventAll(event);
|
||||
|
||||
if (state.isGroup) {
|
||||
const containerRect = containerRef?.current?.getBoundingClientRect?.();
|
||||
const containerRect = containerRef.current?.getBoundingClientRect?.();
|
||||
if (containerRect) {
|
||||
state.containerRectLeft = containerRect.left;
|
||||
state.containerRectTop = containerRect.top;
|
||||
@@ -449,7 +614,9 @@ const useDocumentDrag = (options = {}) => {
|
||||
&& Array.isArray(state.stackDocIds)
|
||||
&& state.stackDocIds.length > 0
|
||||
) {
|
||||
safeInvoke(onDocumentStackSelect, state.stackDocIds, event, { replace: state.stackReplace });
|
||||
safeInvoke(onDocumentStackSelect, state.stackDocIds as Identifier[], event, {
|
||||
replace: state.stackReplace,
|
||||
});
|
||||
state.stackSelectionApplied = true;
|
||||
}
|
||||
if (!state.groupElevated) {
|
||||
@@ -561,7 +728,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
const halfWidth = docWidth / 2;
|
||||
const halfHeight = docHeight / 2;
|
||||
|
||||
const containerRect = containerRef?.current?.getBoundingClientRect?.();
|
||||
const containerRect = containerRef.current?.getBoundingClientRect?.();
|
||||
if (containerRect) {
|
||||
state.containerRectLeft = containerRect.left;
|
||||
state.containerRectTop = containerRect.top;
|
||||
@@ -712,7 +879,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(event) => {
|
||||
(event: PointerEventLike) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state || state.pointerId !== event.pointerId) {
|
||||
finishDrag(event.pointerId);
|
||||
@@ -729,7 +896,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
|
||||
if (state.moved) {
|
||||
commitActiveDragTransforms([state.docKey]);
|
||||
const inertiaState = {
|
||||
const inertiaState: EngineInertiaState = {
|
||||
restRotation: state.restRotation,
|
||||
dynamicRotation: state.dynamicRotation,
|
||||
angularVelocity: state.angularVelocity,
|
||||
@@ -776,7 +943,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
);
|
||||
|
||||
const handlePointerCancel = useCallback(
|
||||
(event) => {
|
||||
(event: PointerEventLike) => {
|
||||
const state = dragStateRef.current;
|
||||
if (state && state.pointerId === event.pointerId && state.moved) {
|
||||
if (state.isGroup) {
|
||||
@@ -788,7 +955,7 @@ const useDocumentDrag = (options = {}) => {
|
||||
}
|
||||
|
||||
commitActiveDragTransforms([state.docKey]);
|
||||
const inertiaState = {
|
||||
const inertiaState: EngineInertiaState = {
|
||||
restRotation: state.restRotation,
|
||||
dynamicRotation: state.dynamicRotation,
|
||||
angularVelocity: state.angularVelocity,
|
||||
+295
-75
@@ -1,5 +1,131 @@
|
||||
import { clamp, formatTransform } from './math.js';
|
||||
import { fetchLayoutRecords, upsertLayoutRecords } from './db.js';
|
||||
import { clamp, formatTransform } from './math';
|
||||
import { fetchLayoutRecords, upsertLayoutRecords } from './db';
|
||||
|
||||
type DocumentId = string;
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
type Polygon = Point[];
|
||||
|
||||
interface TransformOptions {
|
||||
centerX?: number;
|
||||
centerY?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
rotation?: number;
|
||||
scale?: number;
|
||||
zIndex?: number | null;
|
||||
}
|
||||
|
||||
interface CardDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface LayoutEntry {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
z: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface LayoutGenerationEntry {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
seedKey: string;
|
||||
}
|
||||
|
||||
interface LayoutGenerationOptions {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
padding: number;
|
||||
startZ?: number;
|
||||
rotationRange?: number;
|
||||
minSpacing?: number;
|
||||
shelfWidth?: number;
|
||||
}
|
||||
|
||||
interface DocumentSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface BaseMetrics {
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
baseScale: number;
|
||||
}
|
||||
|
||||
interface DragGroupItem {
|
||||
docId?: string | number | null;
|
||||
width: number;
|
||||
height: number;
|
||||
currentCenterX?: number;
|
||||
currentCenterY?: number;
|
||||
displayRotation?: number;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
docKey?: string | null;
|
||||
dragScale?: number;
|
||||
originCenterX?: number;
|
||||
originCenterY?: number;
|
||||
groupItems?: DragGroupItem[] | null;
|
||||
}
|
||||
|
||||
interface InertiaSimulationState {
|
||||
docId: string;
|
||||
restRotation: number;
|
||||
rotation: number;
|
||||
dynamicRotation: number;
|
||||
angularVelocity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dragScale?: number;
|
||||
lastTimestamp: number;
|
||||
frameId?: number;
|
||||
}
|
||||
|
||||
interface WorkspaceSnapshot {
|
||||
layout: Map<DocumentId, LayoutEntry>;
|
||||
canvasSize: { width: number; height: number };
|
||||
visibleDocIds: Set<DocumentId>;
|
||||
draggingId: string | null;
|
||||
tagDropTargetId: string | null;
|
||||
pendingTagDocId: string | null;
|
||||
pendingRemovalTag: unknown;
|
||||
initialLoadDone: boolean;
|
||||
}
|
||||
|
||||
type WorkspaceSubscriber = () => void;
|
||||
|
||||
type DeskDocument = { id?: string | number | null } & Record<string, unknown>;
|
||||
|
||||
type EnsureDocumentSize = (doc: DeskDocument) => DocumentSize | null | undefined;
|
||||
|
||||
type ResolveBaseMetrics = () => BaseMetrics;
|
||||
|
||||
interface ItemRefs {
|
||||
current: Map<string, HTMLElement | null>;
|
||||
}
|
||||
|
||||
interface WorkspaceEngineOptions {
|
||||
allowLayoutPersistence?: boolean;
|
||||
tenantId?: string | null;
|
||||
viewId?: string | null;
|
||||
}
|
||||
|
||||
type UseSyncExternalStoreHook = <State>(
|
||||
subscribe: (listener: () => void) => () => void,
|
||||
getSnapshot: () => State,
|
||||
getServerSnapshot: () => State,
|
||||
) => State;
|
||||
|
||||
export const DESK_CANVAS_PADDING = 24;
|
||||
export const DESK_ROTATION_RANGE = 7;
|
||||
@@ -18,7 +144,7 @@ export const TORQUE_TO_ACCELERATION = 0.006;
|
||||
export const SETTLE_ANGULAR_VELOCITY = 1.2;
|
||||
|
||||
export const applyDomTransform = (
|
||||
node,
|
||||
node: HTMLElement | null | undefined,
|
||||
{
|
||||
centerX,
|
||||
centerY,
|
||||
@@ -27,8 +153,8 @@ export const applyDomTransform = (
|
||||
rotation = 0,
|
||||
scale = 1,
|
||||
zIndex,
|
||||
} = {},
|
||||
) => {
|
||||
}: TransformOptions = {},
|
||||
): void => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
@@ -44,7 +170,7 @@ export const applyDomTransform = (
|
||||
}
|
||||
};
|
||||
|
||||
export const clampCardDimensions = (width, height) => {
|
||||
export const clampCardDimensions = (width: number, height: number): CardDimensions | null => {
|
||||
const w = Number(width);
|
||||
const h = Number(height);
|
||||
|
||||
@@ -56,7 +182,7 @@ export const clampCardDimensions = (width, height) => {
|
||||
const high = Math.min(DESK_CARD_MAX / w, DESK_CARD_MAX / h);
|
||||
|
||||
const candidates = [];
|
||||
const addCandidate = (scale) => {
|
||||
const addCandidate = (scale: number) => {
|
||||
if (Number.isFinite(scale) && scale > 0) {
|
||||
candidates.push(scale);
|
||||
}
|
||||
@@ -66,7 +192,7 @@ export const clampCardDimensions = (width, height) => {
|
||||
addCandidate(low);
|
||||
addCandidate(high);
|
||||
|
||||
const best = candidates.reduce((acc, scale) => {
|
||||
const best = candidates.reduce<{ scale: number; violation: number; deviation: number } | null>((acc, scale) => {
|
||||
const scaledWidth = w * scale;
|
||||
const scaledHeight = h * scale;
|
||||
const violation = Math.max(
|
||||
@@ -89,7 +215,7 @@ export const clampCardDimensions = (width, height) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const computeFallbackCardSize = (docId) => {
|
||||
export const computeFallbackCardSize = (docId: string | number): CardDimensions | null => {
|
||||
const baseSeed = seededRandom(`${docId}:fallback-size`);
|
||||
const aspectSeed = seededRandom(`${docId}:fallback-aspect`);
|
||||
|
||||
@@ -105,7 +231,7 @@ export const computeFallbackCardSize = (docId) => {
|
||||
return clampCardDimensions(width, height);
|
||||
};
|
||||
|
||||
function seededRandom(input) {
|
||||
function seededRandom(input: unknown): number {
|
||||
const text = String(input);
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
@@ -115,22 +241,25 @@ function seededRandom(input) {
|
||||
return (hash >>> 0) / 4294967295;
|
||||
}
|
||||
|
||||
function randomRangeFromSeed(seedKey, min, max) {
|
||||
function randomRangeFromSeed(seedKey: string, min: number, max: number): number {
|
||||
const span = max - min;
|
||||
if (span <= 0) return min;
|
||||
const seed = seededRandom(seedKey);
|
||||
return min + seed * span;
|
||||
}
|
||||
|
||||
function buildKey(docId, suffix) {
|
||||
function buildKey(docId: string | number, suffix: string): string {
|
||||
return `${docId}::${suffix}`;
|
||||
}
|
||||
|
||||
const signedDistanceToEdge = (edgeStart, edgeEnd, point) =>
|
||||
const signedDistanceToEdge = (edgeStart: Point, edgeEnd: Point, point: Point): number =>
|
||||
(edgeEnd.x - edgeStart.x) * (point.y - edgeStart.y)
|
||||
- (edgeEnd.y - edgeStart.y) * (point.x - edgeStart.x);
|
||||
|
||||
const iterateEdges = (polygon, callback) => {
|
||||
const iterateEdges = (
|
||||
polygon: Polygon,
|
||||
callback: (current: Point, next: Point, index: number) => boolean | void,
|
||||
): void => {
|
||||
if (!Array.isArray(polygon) || polygon.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -143,7 +272,10 @@ const iterateEdges = (polygon, callback) => {
|
||||
}
|
||||
};
|
||||
|
||||
const forEachVertex = (polygon, callback) => {
|
||||
const forEachVertex = (
|
||||
polygon: Polygon,
|
||||
callback: (current: Point, previous: Point, index: number) => boolean | void,
|
||||
): void => {
|
||||
if (!Array.isArray(polygon) || polygon.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -156,7 +288,7 @@ const forEachVertex = (polygon, callback) => {
|
||||
}
|
||||
};
|
||||
|
||||
const lineIntersection = (p1, p2, cp1, cp2) => {
|
||||
const lineIntersection = (p1: Point, p2: Point, cp1: Point, cp2: Point): Point => {
|
||||
const A1 = p2.y - p1.y;
|
||||
const B1 = p1.x - p2.x;
|
||||
const C1 = A1 * p1.x + B1 * p1.y;
|
||||
@@ -175,7 +307,7 @@ const lineIntersection = (p1, p2, cp1, cp2) => {
|
||||
};
|
||||
};
|
||||
|
||||
const clipPolygon = (subject, clipper) => {
|
||||
const clipPolygon = (subject: Polygon, clipper: Polygon): Polygon => {
|
||||
if (!Array.isArray(subject) || !subject.length) {
|
||||
return [];
|
||||
}
|
||||
@@ -204,7 +336,7 @@ const clipPolygon = (subject, clipper) => {
|
||||
return output;
|
||||
};
|
||||
|
||||
const isPointInsideConvex = (point, polygon) => {
|
||||
const isPointInsideConvex = (point: Point, polygon: Polygon): boolean => {
|
||||
if (!polygon?.length) {
|
||||
return false;
|
||||
}
|
||||
@@ -229,7 +361,7 @@ const isPointInsideConvex = (point, polygon) => {
|
||||
return inside;
|
||||
};
|
||||
|
||||
const polygonCentroid = (polygon) => {
|
||||
const polygonCentroid = (polygon: Polygon): Point => {
|
||||
if (!polygon?.length) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
@@ -261,7 +393,7 @@ const polygonCentroid = (polygon) => {
|
||||
};
|
||||
};
|
||||
const generateInitialLayout = (
|
||||
entries,
|
||||
entries: LayoutGenerationEntry[],
|
||||
{
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
@@ -270,9 +402,9 @@ const generateInitialLayout = (
|
||||
rotationRange = DESK_ROTATION_RANGE,
|
||||
minSpacing = 48,
|
||||
shelfWidth = 0,
|
||||
},
|
||||
) => {
|
||||
const layout = new Map();
|
||||
}: LayoutGenerationOptions,
|
||||
): { layout: Map<string, LayoutEntry>; maxZ: number } => {
|
||||
const layout = new Map<string, LayoutEntry>();
|
||||
let currentZ = startZ;
|
||||
let maxZ = startZ;
|
||||
|
||||
@@ -282,9 +414,9 @@ const generateInitialLayout = (
|
||||
|
||||
const shelfOffset = Math.max(shelfWidth, 0);
|
||||
const spacingBuffer = Math.max(minSpacing, 0);
|
||||
const placed = [];
|
||||
const placed: Array<{ x: number; y: number; radius: number }> = [];
|
||||
|
||||
const resolveBounds = (width, height) => {
|
||||
const resolveBounds = (width: number, height: number) => {
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
return {
|
||||
@@ -298,7 +430,7 @@ const generateInitialLayout = (
|
||||
};
|
||||
};
|
||||
|
||||
const evaluateCandidateSpacing = (x, y, radius) => {
|
||||
const evaluateCandidateSpacing = (x: number, y: number, radius: number) => {
|
||||
if (!placed.length) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
@@ -381,11 +513,71 @@ const generateInitialLayout = (
|
||||
};
|
||||
|
||||
export class WorkspaceEngine {
|
||||
allowLayoutPersistence: boolean;
|
||||
|
||||
tenantId: string | null;
|
||||
|
||||
viewId: string | null;
|
||||
|
||||
layout: Map<DocumentId, LayoutEntry>;
|
||||
|
||||
layoutSnapshot: Map<DocumentId, LayoutEntry>;
|
||||
|
||||
persistedLayout: Map<DocumentId, LayoutEntry>;
|
||||
|
||||
layoutDirty: boolean;
|
||||
|
||||
zCounter: number;
|
||||
|
||||
canvasSize: { width: number; height: number };
|
||||
|
||||
visibleDocIds: Set<DocumentId>;
|
||||
|
||||
draggingId: string | null;
|
||||
|
||||
tagDropTargetId: string | null;
|
||||
|
||||
pendingTagDocId: string | null;
|
||||
|
||||
pendingRemovalTag: unknown;
|
||||
|
||||
dragInProgress: boolean;
|
||||
|
||||
activeDragDocIds: Set<DocumentId>;
|
||||
|
||||
pendingSnapshotSync: boolean;
|
||||
|
||||
pendingPersistSync: boolean;
|
||||
|
||||
persistDebounceId: number | null;
|
||||
|
||||
items: DeskDocument[];
|
||||
|
||||
documentLookup: Map<string, DeskDocument>;
|
||||
|
||||
ensureDocumentSize: EnsureDocumentSize;
|
||||
|
||||
resolveBaseMetrics: ResolveBaseMetrics;
|
||||
|
||||
snapshotCache: WorkspaceSnapshot;
|
||||
|
||||
subscribers: Set<WorkspaceSubscriber>;
|
||||
|
||||
loadingPersisted: boolean;
|
||||
|
||||
pendingPersistence: unknown;
|
||||
|
||||
itemRefs: ItemRefs;
|
||||
|
||||
inertiaAnimations: Map<string, InertiaSimulationState>;
|
||||
|
||||
initialLoadDone: boolean;
|
||||
|
||||
constructor({
|
||||
allowLayoutPersistence = false,
|
||||
tenantId = null,
|
||||
viewId = null,
|
||||
} = {}) {
|
||||
}: WorkspaceEngineOptions = {}) {
|
||||
this.allowLayoutPersistence = allowLayoutPersistence;
|
||||
this.tenantId = tenantId;
|
||||
this.viewId = viewId;
|
||||
@@ -424,7 +616,7 @@ export class WorkspaceEngine {
|
||||
this.initialLoadDone = false;
|
||||
}
|
||||
|
||||
updateConfig({ allowLayoutPersistence, tenantId, viewId }) {
|
||||
updateConfig({ allowLayoutPersistence, tenantId, viewId }: WorkspaceEngineOptions): void {
|
||||
const allowChanged =
|
||||
typeof allowLayoutPersistence === 'boolean'
|
||||
&& allowLayoutPersistence !== this.allowLayoutPersistence;
|
||||
@@ -470,7 +662,7 @@ export class WorkspaceEngine {
|
||||
}
|
||||
}
|
||||
|
||||
setItems(items) {
|
||||
setItems(items: DeskDocument[] | null | undefined): void {
|
||||
const normalized = Array.isArray(items) ? items : [];
|
||||
this.items = normalized;
|
||||
const canGenerateLayoutImmediately =
|
||||
@@ -484,28 +676,28 @@ export class WorkspaceEngine {
|
||||
this.recalcVisibleDocIds();
|
||||
}
|
||||
|
||||
setDocumentLookup(map) {
|
||||
setDocumentLookup(map: Map<string, DeskDocument>): void {
|
||||
this.documentLookup = map instanceof Map ? map : new Map();
|
||||
this.recalcVisibleDocIds();
|
||||
}
|
||||
|
||||
setEnsureDocumentSize(fn) {
|
||||
setEnsureDocumentSize(fn: EnsureDocumentSize): void {
|
||||
if (typeof fn === 'function') {
|
||||
this.ensureDocumentSize = fn;
|
||||
}
|
||||
}
|
||||
|
||||
setResolveBaseMetrics(fn) {
|
||||
setResolveBaseMetrics(fn: ResolveBaseMetrics): void {
|
||||
if (typeof fn === 'function') {
|
||||
this.resolveBaseMetrics = fn;
|
||||
}
|
||||
}
|
||||
|
||||
setItemRefs(ref) {
|
||||
setItemRefs(ref: ItemRefs | null | undefined): void {
|
||||
this.itemRefs = ref || { current: new Map() };
|
||||
}
|
||||
|
||||
setCanvasSize(size) {
|
||||
setCanvasSize(size: { width?: number | null; height?: number | null }): void {
|
||||
const width = Number(size?.width) || 0;
|
||||
const height = Number(size?.height) || 0;
|
||||
if (this.canvasSize.width === width && this.canvasSize.height === height) {
|
||||
@@ -517,7 +709,7 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setDraggingId(docId) {
|
||||
setDraggingId(docId: string | number | null): void {
|
||||
const normalized = docId != null ? String(docId) : null;
|
||||
if (this.draggingId === normalized) {
|
||||
return;
|
||||
@@ -526,7 +718,7 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
beginDrag(docIds = []) {
|
||||
beginDrag(docIds: Array<string | number | null> = []): void {
|
||||
this.dragInProgress = true;
|
||||
if (Array.isArray(docIds)) {
|
||||
this.activeDragDocIds = new Set(docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean));
|
||||
@@ -535,13 +727,13 @@ export class WorkspaceEngine {
|
||||
}
|
||||
}
|
||||
|
||||
endDrag() {
|
||||
endDrag(): void {
|
||||
this.dragInProgress = false;
|
||||
this.activeDragDocIds.clear();
|
||||
this.flushPendingLayoutOps();
|
||||
}
|
||||
|
||||
flushPendingLayoutOps() {
|
||||
flushPendingLayoutOps(): void {
|
||||
if (this.pendingSnapshotSync) {
|
||||
this.syncLayoutSnapshot();
|
||||
}
|
||||
@@ -550,7 +742,7 @@ export class WorkspaceEngine {
|
||||
}
|
||||
}
|
||||
|
||||
setTagDropTargetId(docId) {
|
||||
setTagDropTargetId(docId: string | number | null): void {
|
||||
const normalized = docId != null ? String(docId) : null;
|
||||
if (this.tagDropTargetId === normalized) {
|
||||
return;
|
||||
@@ -559,7 +751,7 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setPendingTagDocId(docId) {
|
||||
setPendingTagDocId(docId: string | number | null): void {
|
||||
const normalized = docId != null ? String(docId) : null;
|
||||
if (this.pendingTagDocId === normalized) {
|
||||
return;
|
||||
@@ -568,7 +760,7 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setPendingRemovalTag(payload) {
|
||||
setPendingRemovalTag(payload: unknown): void {
|
||||
if (payload === this.pendingRemovalTag) {
|
||||
return;
|
||||
}
|
||||
@@ -576,11 +768,11 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
markLayoutDirty() {
|
||||
markLayoutDirty(): void {
|
||||
this.layoutDirty = true;
|
||||
}
|
||||
|
||||
getLayout(docId) {
|
||||
getLayout(docId: string | number | null): LayoutEntry | null {
|
||||
if (docId == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -588,13 +780,20 @@ export class WorkspaceEngine {
|
||||
return this.layout.get(key) || null;
|
||||
}
|
||||
|
||||
updateLayoutEntry(docId, updater) {
|
||||
updateLayoutEntry(
|
||||
docId: string | number | null,
|
||||
updater:
|
||||
| LayoutEntry
|
||||
| null
|
||||
| undefined
|
||||
| ((previous: LayoutEntry | null) => LayoutEntry | null | undefined),
|
||||
): void {
|
||||
if (docId == null) {
|
||||
return;
|
||||
}
|
||||
const key = String(docId);
|
||||
const previous = this.layout.get(key) || null;
|
||||
const next = typeof updater === 'function' ? updater(previous || {}) : updater;
|
||||
const next = typeof updater === 'function' ? updater(previous) : updater;
|
||||
if (!next) {
|
||||
this.layout.delete(key);
|
||||
} else {
|
||||
@@ -605,7 +804,7 @@ export class WorkspaceEngine {
|
||||
this.persistLayoutSnapshot();
|
||||
}
|
||||
|
||||
bringToFront(docId) {
|
||||
bringToFront(docId: string | number | null): void {
|
||||
if (docId == null) {
|
||||
return;
|
||||
}
|
||||
@@ -622,7 +821,16 @@ export class WorkspaceEngine {
|
||||
this.recalcVisibleDocIds();
|
||||
}
|
||||
|
||||
applyTransform(docId, centerX, centerY, width, height, rotation, scale = 1, zIndex = null) {
|
||||
applyTransform(
|
||||
docId: string | number | null,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
rotation: number,
|
||||
scale = 1,
|
||||
zIndex: number | null = null,
|
||||
): void {
|
||||
const key = docId != null ? String(docId) : null;
|
||||
if (!key) {
|
||||
return;
|
||||
@@ -639,7 +847,7 @@ export class WorkspaceEngine {
|
||||
});
|
||||
}
|
||||
|
||||
finalizeGroupDrag(dragState) {
|
||||
finalizeGroupDrag(dragState: DragState): void {
|
||||
if (!dragState?.groupItems) {
|
||||
return;
|
||||
}
|
||||
@@ -652,16 +860,18 @@ export class WorkspaceEngine {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const entry = this.layout.get(key) || {};
|
||||
const centerX = item.currentCenterX ?? entry.centerX ?? dragState.originCenterX;
|
||||
const centerY = item.currentCenterY ?? entry.centerY ?? dragState.originCenterY;
|
||||
const rotation = item.displayRotation ?? entry.rotation ?? 0;
|
||||
const entry = this.layout.get(key);
|
||||
const centerX = item.currentCenterX ?? entry?.centerX ?? dragState.originCenterX ?? 0;
|
||||
const centerY = item.currentCenterY ?? entry?.centerY ?? dragState.originCenterY ?? 0;
|
||||
const rotation = item.displayRotation ?? entry?.rotation ?? 0;
|
||||
|
||||
const nextEntry = {
|
||||
...entry,
|
||||
const nextEntry: LayoutEntry = {
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
z: entry?.z ?? this.zCounter,
|
||||
width: entry?.width ?? item.width,
|
||||
height: entry?.height ?? item.height,
|
||||
};
|
||||
|
||||
this.layout.set(key, nextEntry);
|
||||
@@ -683,7 +893,7 @@ export class WorkspaceEngine {
|
||||
this.persistLayoutSnapshot();
|
||||
}
|
||||
|
||||
cancelInertiaAnimation(docId) {
|
||||
cancelInertiaAnimation(docId: string | number | null): void {
|
||||
const key = docId != null ? String(docId) : null;
|
||||
if (!key) {
|
||||
return;
|
||||
@@ -695,7 +905,7 @@ export class WorkspaceEngine {
|
||||
this.inertiaAnimations.delete(key);
|
||||
}
|
||||
|
||||
disposeInertiaAnimations() {
|
||||
disposeInertiaAnimations(): void {
|
||||
this.inertiaAnimations.forEach((animation) => {
|
||||
if (animation?.frameId != null) {
|
||||
window.cancelAnimationFrame(animation.frameId);
|
||||
@@ -704,7 +914,12 @@ export class WorkspaceEngine {
|
||||
this.inertiaAnimations.clear();
|
||||
}
|
||||
|
||||
integrateRotation(simulationState, dt, torque = 0, dampingOverride = null) {
|
||||
integrateRotation(
|
||||
simulationState: InertiaSimulationState,
|
||||
dt: number,
|
||||
torque = 0,
|
||||
dampingOverride: number | null = null,
|
||||
): boolean {
|
||||
const key = simulationState.docId != null ? String(simulationState.docId) : null;
|
||||
if (!key) {
|
||||
return true;
|
||||
@@ -761,7 +976,7 @@ export class WorkspaceEngine {
|
||||
return isSettled;
|
||||
}
|
||||
|
||||
startInertiaAnimation(docId, baseState) {
|
||||
startInertiaAnimation(docId: string | number | null, baseState: InertiaSimulationState): void {
|
||||
const raf = window.requestAnimationFrame;
|
||||
if (!raf) {
|
||||
return;
|
||||
@@ -782,7 +997,7 @@ export class WorkspaceEngine {
|
||||
lastTimestamp: now,
|
||||
};
|
||||
|
||||
const step = (timestamp) => {
|
||||
const step = (timestamp: number) => {
|
||||
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
|
||||
const previous = simulationState.lastTimestamp;
|
||||
let dt = (safeTimestamp - previous) / 1000;
|
||||
@@ -806,7 +1021,7 @@ export class WorkspaceEngine {
|
||||
this.inertiaAnimations.set(key, simulationState);
|
||||
}
|
||||
|
||||
syncLayoutSnapshot() {
|
||||
syncLayoutSnapshot(): void {
|
||||
if (this.dragInProgress) {
|
||||
this.pendingSnapshotSync = true;
|
||||
return;
|
||||
@@ -816,7 +1031,7 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
async persistLayoutSnapshot() {
|
||||
async persistLayoutSnapshot(): Promise<void> {
|
||||
if (this.dragInProgress) {
|
||||
this.pendingPersistSync = true;
|
||||
return;
|
||||
@@ -883,7 +1098,7 @@ export class WorkspaceEngine {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
ensureLayoutForItems() {
|
||||
ensureLayoutForItems(): void {
|
||||
const persistenceReady = !this.allowLayoutPersistence || !this.tenantId || !this.viewId || this.initialLoadDone;
|
||||
const canvasReady = Boolean(this.canvasSize.width && this.canvasSize.height);
|
||||
const sizesReady = !this.items.some((doc) => !this.ensureDocumentSize(doc));
|
||||
@@ -907,11 +1122,11 @@ export class WorkspaceEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Map();
|
||||
const next = new Map<DocumentId, LayoutEntry>();
|
||||
let maxZ = this.zCounter;
|
||||
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
|
||||
const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT;
|
||||
const docsNeedingLayout = [];
|
||||
const docsNeedingLayout: LayoutGenerationEntry[] = [];
|
||||
|
||||
const currentEntries = new Map(this.layout);
|
||||
|
||||
@@ -984,7 +1199,7 @@ export class WorkspaceEngine {
|
||||
this.recalcVisibleDocIds();
|
||||
}
|
||||
|
||||
recalcVisibleDocIds() {
|
||||
recalcVisibleDocIds(): void {
|
||||
const ensureSize = this.ensureDocumentSize;
|
||||
if (typeof ensureSize !== 'function') {
|
||||
return;
|
||||
@@ -1009,7 +1224,7 @@ export class WorkspaceEngine {
|
||||
{ x: 0, y: canvasHeight },
|
||||
];
|
||||
|
||||
const entries = [];
|
||||
const entries: Array<{ key: string; z: number; polygon: Polygon }> = [];
|
||||
layoutMap.forEach((entry, docKey) => {
|
||||
if (!docKey) {
|
||||
return;
|
||||
@@ -1062,8 +1277,8 @@ export class WorkspaceEngine {
|
||||
|
||||
entries.sort((a, b) => (b.z || 0) - (a.z || 0));
|
||||
|
||||
const visiblePolygons = [];
|
||||
const result = new Set();
|
||||
const visiblePolygons: Polygon[] = [];
|
||||
const result = new Set<DocumentId>();
|
||||
|
||||
entries.forEach(({ key, polygon }) => {
|
||||
if (polygon.length < 3) {
|
||||
@@ -1117,16 +1332,16 @@ export class WorkspaceEngine {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
subscribe(listener: WorkspaceSubscriber): () => void {
|
||||
this.subscribers.add(listener);
|
||||
return () => {
|
||||
this.subscribers.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot = () => this.snapshotCache;
|
||||
getSnapshot = (): WorkspaceSnapshot => this.snapshotCache;
|
||||
|
||||
buildSnapshot() {
|
||||
buildSnapshot(): WorkspaceSnapshot {
|
||||
return {
|
||||
layout: this.layoutSnapshot,
|
||||
canvasSize: this.canvasSize,
|
||||
@@ -1139,7 +1354,7 @@ export class WorkspaceEngine {
|
||||
};
|
||||
}
|
||||
|
||||
emit() {
|
||||
emit(): void {
|
||||
this.snapshotCache = this.buildSnapshot();
|
||||
this.subscribers.forEach((listener) => {
|
||||
try {
|
||||
@@ -1150,7 +1365,7 @@ export class WorkspaceEngine {
|
||||
});
|
||||
}
|
||||
|
||||
async loadPersistedLayout() {
|
||||
async loadPersistedLayout(): Promise<void> {
|
||||
if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) {
|
||||
return;
|
||||
}
|
||||
@@ -1198,7 +1413,10 @@ export class WorkspaceEngine {
|
||||
}
|
||||
}
|
||||
|
||||
export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => {
|
||||
export const useWorkspaceSnapshot = (
|
||||
engine: WorkspaceEngine,
|
||||
useSyncExternalStoreHook: UseSyncExternalStoreHook,
|
||||
): WorkspaceSnapshot => {
|
||||
const useSyncExternalStore = useSyncExternalStoreHook;
|
||||
if (typeof useSyncExternalStore !== 'function') {
|
||||
throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook');
|
||||
@@ -1211,7 +1429,9 @@ export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => {
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
const commonJsModule = globalThis?.module;
|
||||
const commonJsModule = (globalThis as typeof globalThis & {
|
||||
module?: { exports?: Record<string, unknown> };
|
||||
}).module;
|
||||
if (commonJsModule?.exports) {
|
||||
commonJsModule.exports = {
|
||||
WorkspaceEngine,
|
||||
+67
-53
@@ -1,27 +1,45 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { CSSProperties, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||
import { clamp } from '../utils/math';
|
||||
|
||||
type PreviewNavigator = {
|
||||
url: string;
|
||||
alt?: string;
|
||||
canGoPrev?: boolean;
|
||||
canGoNext?: boolean;
|
||||
goPrev?: () => void;
|
||||
goNext?: () => void;
|
||||
};
|
||||
|
||||
interface PreviewZoomOverlayProps {
|
||||
open?: boolean;
|
||||
display?: PreviewNavigator | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type NaturalSize = { width: number | null; height: number | null };
|
||||
type FocusPoint = { xRatio: number; yRatio: number } | null;
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const PreviewZoomOverlay = ({
|
||||
const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
||||
open = false,
|
||||
display = null,
|
||||
onClose = noop,
|
||||
}) => {
|
||||
const portalTarget = document.body;
|
||||
const portalTarget = typeof document !== 'undefined' ? document.body : null;
|
||||
const [isNativeScale, setIsNativeScale] = useState(false);
|
||||
const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
|
||||
const [naturalSize, setNaturalSize] = useState<NaturalSize>({ width: null, height: null });
|
||||
const [renderBackdrop, setRenderBackdrop] = useState(false);
|
||||
const [isBackdropVisible, setBackdropVisible] = useState(false);
|
||||
const [displaySnapshot, setDisplaySnapshot] = useState(null);
|
||||
const scrollRef = useRef(null);
|
||||
const imageRef = useRef(null);
|
||||
const focusRef = useRef(null);
|
||||
const previouslyFocusedRef = useRef(null);
|
||||
const visibilityTimerRef = useRef(null);
|
||||
const displayTimerRef = useRef(null);
|
||||
const [displaySnapshot, setDisplaySnapshot] = useState<PreviewNavigator | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const focusRef = useRef<FocusPoint>(null);
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
||||
const visibilityTimerRef = useRef<number | null>(null);
|
||||
const displayTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (display?.url) {
|
||||
@@ -55,7 +73,7 @@ const PreviewZoomOverlay = ({
|
||||
}
|
||||
|
||||
setBackdropVisible(false);
|
||||
visibilityTimerRef.current = setTimeout(() => {
|
||||
visibilityTimerRef.current = window.setTimeout(() => {
|
||||
setRenderBackdrop(false);
|
||||
}, 260);
|
||||
|
||||
@@ -117,6 +135,9 @@ const PreviewZoomOverlay = ({
|
||||
}, [open, isNativeScale, naturalSize.width, naturalSize.height]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!open) {
|
||||
previouslyFocusedRef.current?.focus?.();
|
||||
previouslyFocusedRef.current = null;
|
||||
@@ -124,7 +145,7 @@ const PreviewZoomOverlay = ({
|
||||
}
|
||||
|
||||
const active = document.activeElement;
|
||||
previouslyFocusedRef.current = active?.focus ? active : null;
|
||||
previouslyFocusedRef.current = active instanceof HTMLElement ? active : null;
|
||||
}, [open]);
|
||||
|
||||
const activeDisplay = open && display?.url ? display : displaySnapshot;
|
||||
@@ -154,7 +175,7 @@ const PreviewZoomOverlay = ({
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [renderBackdrop, activeDisplay?.url]);
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!open) {
|
||||
@@ -203,7 +224,7 @@ const PreviewZoomOverlay = ({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleZoomAtPoint = (clientX, clientY) => {
|
||||
const toggleZoomAtPoint = (clientX: number, clientY: number) => {
|
||||
const img = imageRef.current;
|
||||
setIsNativeScale((current) => {
|
||||
if (!current && img) {
|
||||
@@ -221,7 +242,7 @@ const PreviewZoomOverlay = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleImageClick = (event) => {
|
||||
const handleImageClick = (event: React.MouseEvent<HTMLImageElement>) => {
|
||||
event.stopPropagation();
|
||||
toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0);
|
||||
};
|
||||
@@ -232,11 +253,7 @@ const PreviewZoomOverlay = ({
|
||||
|
||||
const effectiveDisplay = activeDisplay;
|
||||
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
|
||||
const stageClassName = [
|
||||
'preview-zoom__stage',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const stageClassName = 'preview-zoom__stage';
|
||||
|
||||
const containerClassName = [
|
||||
'preview-zoom__scroll',
|
||||
@@ -252,7 +269,7 @@ const PreviewZoomOverlay = ({
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const imageStyle = isNativeScale
|
||||
const imageStyle: CSSProperties = isNativeScale
|
||||
? {
|
||||
cursor: 'zoom-out',
|
||||
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
|
||||
@@ -277,37 +294,34 @@ const PreviewZoomOverlay = ({
|
||||
aria-label="Enlarged document preview"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={stageClassName}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
className={containerClassName}
|
||||
ref={scrollRef}
|
||||
tabIndex={-1}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={effectiveDisplay.url}
|
||||
alt={effectiveDisplay.alt || 'Document preview'}
|
||||
className="preview-zoom__image"
|
||||
ref={imageRef}
|
||||
draggable={false}
|
||||
onLoad={(event) => {
|
||||
setNaturalSize({
|
||||
width: event.currentTarget.naturalWidth || null,
|
||||
height: event.currentTarget.naturalHeight || null,
|
||||
});
|
||||
}}
|
||||
onClick={handleImageClick}
|
||||
style={imageStyle}
|
||||
/>
|
||||
</div>
|
||||
{navVisible ? (
|
||||
<div className="preview-zoom__nav">
|
||||
<button
|
||||
type="button"
|
||||
className="preview-zoom__nav-button"
|
||||
<div className={stageClassName} onKeyDown={handleKeyDown}>
|
||||
<div
|
||||
className={containerClassName}
|
||||
ref={scrollRef}
|
||||
tabIndex={-1}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={effectiveDisplay.url}
|
||||
alt={effectiveDisplay.alt || 'Document preview'}
|
||||
className="preview-zoom__image"
|
||||
ref={imageRef}
|
||||
draggable={false}
|
||||
onLoad={(event) => {
|
||||
setNaturalSize({
|
||||
width: event.currentTarget.naturalWidth || null,
|
||||
height: event.currentTarget.naturalHeight || null,
|
||||
});
|
||||
}}
|
||||
onClick={handleImageClick}
|
||||
style={imageStyle}
|
||||
/>
|
||||
</div>
|
||||
{navVisible ? (
|
||||
<div className="preview-zoom__nav">
|
||||
<button
|
||||
type="button"
|
||||
className="preview-zoom__nav-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) {
|
||||
+71
-1
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||
import { useDetailPanel } from '../app/useDetailPanel';
|
||||
import {
|
||||
@@ -6,6 +7,75 @@ import {
|
||||
getRowId,
|
||||
isDocumentRowKey,
|
||||
} from '../app/appLayoutUtils';
|
||||
import type { DocumentInfoPanelProps } from '../documents/DocumentInfoPanel';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier;
|
||||
folder_id?: Identifier | 'root';
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderNode {
|
||||
id: Identifier | 'root';
|
||||
name?: string;
|
||||
parentId?: Identifier | 'root';
|
||||
}
|
||||
|
||||
type PreviewEntry = {
|
||||
url?: string;
|
||||
canGoPrev?: boolean;
|
||||
canGoNext?: boolean;
|
||||
goPrev?: () => void;
|
||||
goNext?: () => void;
|
||||
} | null;
|
||||
|
||||
interface UseDetailWorkspaceArgs {
|
||||
documents: DocumentLike[];
|
||||
searchResults?: DocumentLike[] | null;
|
||||
previewDocuments?: Map<Identifier, DocumentLike> | null;
|
||||
selectionOrder: string[];
|
||||
selectedDocumentIds: Identifier[];
|
||||
documentLookup: Map<Identifier, DocumentLike>;
|
||||
folderNodes: Map<Identifier | 'root', FolderNode>;
|
||||
ensureFolderData: (folderId: Identifier | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise<void>;
|
||||
detailPanelControlRef: MutableRefObject<{ open?: (args?: { documentIds?: Identifier[] }) => void; close?: () => void } | null>;
|
||||
detailFolderFetchRef: MutableRefObject<Set<Identifier | 'root'>>;
|
||||
previewEntries: Map<Identifier, PreviewEntry>;
|
||||
previewDocumentId?: Identifier | null;
|
||||
activePreviewId?: Identifier | null;
|
||||
openDocumentPreview?: (args: { documentIds: Identifier[] }) => void;
|
||||
handleDocumentTitleUpdate?: (docId: Identifier, title: string) => Promise<boolean> | boolean;
|
||||
handleDocumentIssuedUpdate?: (docId: Identifier, issued: number | null) => Promise<boolean> | boolean;
|
||||
handleDocumentTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
|
||||
handleTagRemove?: (...args: unknown[]) => void;
|
||||
ensureAssetUrl?: (...args: unknown[]) => unknown;
|
||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||
ensurePreviewData?: (docId: Identifier, options?: Record<string, unknown>) => Promise<DocumentLike | null | undefined>;
|
||||
correspondents?: unknown[];
|
||||
handleCorrespondentAdd?: (...args: unknown[]) => void;
|
||||
handleCorrespondentRemove?: (...args: unknown[]) => void;
|
||||
resolveApiPath?: (path: string) => string;
|
||||
selectFolder?: (folderId?: Identifier | 'root') => void;
|
||||
tags?: unknown[];
|
||||
tagLookupById?: Map<Identifier, unknown> | null;
|
||||
}
|
||||
|
||||
interface UseDetailWorkspaceResult {
|
||||
detailPanelProps: DocumentInfoPanelProps;
|
||||
detailPanelOpen: boolean;
|
||||
openDetailPanel: ReturnType<typeof useDetailPanel>['openDetailPanel'];
|
||||
closeDetailPanel: ReturnType<typeof useDetailPanel>['closeDetailPanel'];
|
||||
handleDetailPanelClose: () => void;
|
||||
inspectDocument: (docId: Identifier | null) => void;
|
||||
previewActive: boolean;
|
||||
previewWorkspaceDocument: DocumentLike | null;
|
||||
previewWorkspaceEntry: PreviewEntry;
|
||||
resolveThumbnailUrlForDoc: (doc: DocumentLike | null) => string | null;
|
||||
resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>;
|
||||
}
|
||||
|
||||
const useDetailWorkspace = ({
|
||||
documents,
|
||||
@@ -36,7 +106,7 @@ const useDetailWorkspace = ({
|
||||
selectFolder,
|
||||
tags,
|
||||
tagLookupById,
|
||||
}) => {
|
||||
}: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => {
|
||||
const orderedSelectedDocuments = useMemo(() => {
|
||||
const ordered = [];
|
||||
const seen = new Set();
|
||||
+18
-5
@@ -2,7 +2,19 @@ import React from 'react';
|
||||
|
||||
const NBSP = String.fromCharCode(160);
|
||||
|
||||
const CorrespondentLinks = ({
|
||||
export interface CorrespondentLinkEntry {
|
||||
id?: string | number | null;
|
||||
name?: string | null;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface CorrespondentLinksProps {
|
||||
correspondents?: CorrespondentLinkEntry[];
|
||||
activeCorrespondentIdSet?: Set<string | number>;
|
||||
onCorrespondentClick?: (id: string | number) => void;
|
||||
}
|
||||
|
||||
const CorrespondentLinks: React.FC<CorrespondentLinksProps> = ({
|
||||
correspondents,
|
||||
activeCorrespondentIdSet,
|
||||
onCorrespondentClick,
|
||||
@@ -11,8 +23,8 @@ const CorrespondentLinks = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeSet = activeCorrespondentIdSet || new Set();
|
||||
const handleClick = (event, correspondent) => {
|
||||
const activeSet = activeCorrespondentIdSet || new Set<string | number>();
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>, correspondent: CorrespondentLinkEntry) => {
|
||||
if (!onCorrespondentClick || correspondent.id == null) {
|
||||
return;
|
||||
}
|
||||
@@ -27,11 +39,12 @@ const CorrespondentLinks = ({
|
||||
if (isActive) classNames.push('is-active');
|
||||
if (!hasHandler) classNames.push('is-static');
|
||||
const isLast = index === correspondents.length - 1;
|
||||
const label = isLast ? `${correspondent.name}:${NBSP}` : correspondent.name;
|
||||
const fallbackLabel = correspondent.name ?? '—';
|
||||
const label = isLast ? `${fallbackLabel}:${NBSP}` : fallbackLabel;
|
||||
|
||||
return (
|
||||
<React.Fragment
|
||||
key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}
|
||||
key={correspondent.key ?? correspondent.id ?? `${fallbackLabel}-${index}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
+66
-24
@@ -1,8 +1,54 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import DocumentSummarySection from './DocumentSummarySection';
|
||||
import type { ReactNode } from 'react';
|
||||
import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection';
|
||||
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
|
||||
|
||||
const DocumentInfoPanel = ({
|
||||
type PanelTab = { id: string; label: string; render: () => ReactNode };
|
||||
|
||||
type ContentState =
|
||||
| { status: 'idle'; data: null; error: null }
|
||||
| { status: 'loading'; data: null; error: null }
|
||||
| { status: 'loaded'; data: string; error: null }
|
||||
| { status: 'empty'; data: string; error: null }
|
||||
| { status: 'unavailable'; data: null; error: null }
|
||||
| { status: 'error'; data: null; error: unknown };
|
||||
|
||||
interface DocumentInfoPanelProps {
|
||||
document: DocumentSummarySectionProps['document'];
|
||||
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'detailItems' | 'layout'>;
|
||||
metadataItems?: Array<{ label: string; value?: string }>;
|
||||
metadataPayload?: Record<string, unknown>;
|
||||
metadataTabLabel?: string;
|
||||
detailsTabLabel?: string;
|
||||
contentConfig?: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
enabled?: boolean;
|
||||
forceDisplay?: boolean;
|
||||
loadContent?: (args: { signal: AbortSignal }) => Promise<string>;
|
||||
onCancel?: () => void;
|
||||
loadingMessage?: string;
|
||||
emptyMessage?: string;
|
||||
unavailableMessage?: string;
|
||||
errorMessage?: string;
|
||||
renderContent?: (data: string) => ReactNode;
|
||||
} | null;
|
||||
activeTab?: string;
|
||||
onTabChange?: (tabId: string) => void;
|
||||
defaultTabId?: string;
|
||||
resetKey?: string | number | null;
|
||||
classNamePrefix?: string;
|
||||
hideTabNavWhenSingle?: boolean;
|
||||
summaryPlacement?: 'inline' | 'tabs';
|
||||
summaryTabLabel?: string;
|
||||
summaryTabId?: string;
|
||||
leadingTabs?: PanelTab[];
|
||||
trailingTabs?: PanelTab[];
|
||||
tabsPlacement?: 'top' | 'bottom';
|
||||
summaryLayout?: 'default' | 'compact';
|
||||
}
|
||||
|
||||
const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
|
||||
document,
|
||||
summaryProps = {},
|
||||
metadataItems: metadataItemsProp,
|
||||
@@ -42,9 +88,9 @@ const DocumentInfoPanel = ({
|
||||
|
||||
const contentConfig = contentConfigProp || null;
|
||||
const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true));
|
||||
const showContentTab = Boolean(contentConfig && ((contentConfig.forceDisplay ?? contentEnabled)));
|
||||
const showContentTab = Boolean(contentConfig && (contentConfig.forceDisplay ?? contentEnabled));
|
||||
|
||||
const [contentState, setContentState] = useState(() => {
|
||||
const [contentState, setContentState] = useState<ContentState | null>(() => {
|
||||
if (!contentConfig) {
|
||||
return null;
|
||||
}
|
||||
@@ -144,14 +190,14 @@ const DocumentInfoPanel = ({
|
||||
|
||||
const normalizedLeadingTabs = useMemo(
|
||||
() => (Array.isArray(leadingTabs)
|
||||
? leadingTabs.filter((tab) => tab && tab.id && tab.label)
|
||||
? leadingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
|
||||
: []),
|
||||
[leadingTabs],
|
||||
);
|
||||
|
||||
const normalizedTrailingTabs = useMemo(
|
||||
() => (Array.isArray(trailingTabs)
|
||||
? trailingTabs.filter((tab) => tab && tab.id && tab.label)
|
||||
? trailingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
|
||||
: []),
|
||||
[trailingTabs],
|
||||
);
|
||||
@@ -166,7 +212,7 @@ const DocumentInfoPanel = ({
|
||||
: null;
|
||||
|
||||
const visibleTabs = useMemo(() => {
|
||||
const tabsList = [];
|
||||
const tabsList: PanelTab[] = [];
|
||||
|
||||
if (normalizedLeadingTabs.length) {
|
||||
tabsList.push(...normalizedLeadingTabs);
|
||||
@@ -251,17 +297,17 @@ const DocumentInfoPanel = ({
|
||||
}
|
||||
|
||||
if (metadataPayload) {
|
||||
tabsList.push({
|
||||
id: 'metadata',
|
||||
label: metadataTabLabel,
|
||||
render: () => (
|
||||
<section className={`${base}__section ${base}__section--metadata-json`}>
|
||||
<pre className={`${base}__metadata-json`}>
|
||||
{JSON.stringify(metadataPayload, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
),
|
||||
});
|
||||
tabsList.push({
|
||||
id: 'metadata',
|
||||
label: metadataTabLabel,
|
||||
render: () => (
|
||||
<section className={`${base}__section ${base}__section--metadata-json`}>
|
||||
<pre className={`${base}__metadata-json`}>
|
||||
{JSON.stringify(metadataPayload, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedTrailingTabs.length) {
|
||||
@@ -295,18 +341,14 @@ const DocumentInfoPanel = ({
|
||||
return visibleTabs[0].id;
|
||||
}, [visibleTabs, defaultTabId]);
|
||||
|
||||
const renderTabContent = (tab, context = {}) => {
|
||||
const renderTabContent = (tab?: PanelTab | null, context: Record<string, unknown> = {}) => {
|
||||
if (!tab) {
|
||||
return null;
|
||||
}
|
||||
if (typeof tab.render === 'function') {
|
||||
return tab.render(context);
|
||||
}
|
||||
if (tab.component) {
|
||||
const TabComponent = tab.component;
|
||||
return <TabComponent {...context} />;
|
||||
}
|
||||
return React.isValidElement(tab.render) ? tab.render : null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null;
|
||||
+118
-38
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { EditIcon, IconX, PlusIcon } from '../ui/icons';
|
||||
import SelectionAssignmentMenu from './SelectionAssignmentMenu';
|
||||
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import {
|
||||
formatDate,
|
||||
@@ -9,6 +10,68 @@ import {
|
||||
} from '../utils/date';
|
||||
import { describeDocumentSummary } from './documentSummary';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface TagEntry {
|
||||
id?: Identifier;
|
||||
label?: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface CorrespondentEntry {
|
||||
id?: Identifier;
|
||||
name?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier;
|
||||
title?: string;
|
||||
issued_at?: string | null;
|
||||
current_version?: { version_number?: number } | null;
|
||||
tags?: TagEntry[];
|
||||
correspondents?: CorrespondentEntry[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TagSectionProps {
|
||||
tags?: TagEntry[];
|
||||
onRemove?: (tag: TagEntry) => void;
|
||||
onAdd?: (payload: { value: string; option?: unknown; input?: unknown }) => void;
|
||||
emptyMessage?: string;
|
||||
addPlaceholder?: string;
|
||||
addButtonLabel?: string;
|
||||
datalistOptions?: Array<SelectionAssignmentMenuItem | string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CorrespondentSectionProps {
|
||||
entries?: CorrespondentEntry[];
|
||||
onRemove?: (entry: CorrespondentEntry) => void;
|
||||
onAdd?: (payload: { name: string; option?: unknown; input?: unknown }) => void;
|
||||
showCount?: boolean;
|
||||
addPlaceholder?: string;
|
||||
addButtonLabel?: string;
|
||||
datalistOptions?: Array<SelectionAssignmentMenuItem | string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface DocumentSummarySectionProps {
|
||||
document?: DocumentLike | null;
|
||||
tagLookupById?: Map<Identifier, TagEntry>;
|
||||
tagOptions?: SelectionAssignmentMenuItem[];
|
||||
onTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
|
||||
onTagRemove?: (docId: Identifier | undefined, tagId: Identifier | undefined) => void;
|
||||
correspondents?: CorrespondentEntry[];
|
||||
correspondentOptions?: SelectionAssignmentMenuItem[];
|
||||
onCorrespondentAdd?: (payload: { document: DocumentLike; name: string; option?: unknown }) => void;
|
||||
onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void;
|
||||
onUpdateTitle?: (docId: Identifier | undefined, title: string) => Promise<boolean> | boolean;
|
||||
onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise<boolean> | boolean;
|
||||
layout?: 'default' | 'compact';
|
||||
detailItems?: Array<{ label?: string; value?: string }>;
|
||||
}
|
||||
|
||||
export const sortCorrespondents = (entries = []) =>
|
||||
entries
|
||||
.filter((entry) => entry && entry.name)
|
||||
@@ -32,15 +95,28 @@ export const buildCorrespondentOptions = (entries = []) => {
|
||||
}, []);
|
||||
};
|
||||
|
||||
const normalizeOptions = (options) => (Array.isArray(options) ? options : []);
|
||||
const normalizeOptions = <T,>(options?: T[] | null): T[] => (Array.isArray(options) ? options : []);
|
||||
|
||||
const normalizeQuickAddOption = (option) => {
|
||||
interface QuickAddOption {
|
||||
id?: Identifier;
|
||||
label?: string;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface QuickAddEntry {
|
||||
id: Identifier | string;
|
||||
label: string;
|
||||
original: QuickAddOption | string;
|
||||
}
|
||||
|
||||
const normalizeQuickAddOption = (option: QuickAddOption | string | null | undefined): QuickAddEntry | null => {
|
||||
if (option == null) {
|
||||
return null;
|
||||
}
|
||||
const isObject = typeof option === 'object';
|
||||
const labelSource = isObject ? option.label ?? option.name ?? '' : option;
|
||||
const label = labelSource?.trim?.() || '';
|
||||
const label = typeof labelSource === 'string' ? labelSource.trim() : '';
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
@@ -51,7 +127,7 @@ const normalizeQuickAddOption = (option) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const TagSection = ({
|
||||
export const TagSection: React.FC<TagSectionProps> = ({
|
||||
tags = [],
|
||||
onRemove,
|
||||
onAdd,
|
||||
@@ -62,17 +138,17 @@ export const TagSection = ({
|
||||
className,
|
||||
}) => {
|
||||
const handleCreate = useCallback(
|
||||
(label) => onAdd?.({ value: label, input: null }),
|
||||
(label: string) => onAdd?.({ value: label, input: null }),
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(option) => {
|
||||
(option: { label?: string; name?: string } | string | null) => {
|
||||
if (!onAdd) return;
|
||||
const labelSource = option && typeof option === 'object'
|
||||
? option.label ?? option.name ?? ''
|
||||
: option;
|
||||
const label = labelSource?.trim?.() || '';
|
||||
const label = typeof labelSource === 'string' ? labelSource.trim() : '';
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
@@ -85,14 +161,14 @@ export const TagSection = ({
|
||||
() =>
|
||||
normalizeOptions(datalistOptions)
|
||||
.map((option) => normalizeQuickAddOption(option))
|
||||
.filter(Boolean),
|
||||
.filter((option): option is QuickAddEntry => Boolean(option)),
|
||||
[datalistOptions],
|
||||
);
|
||||
const containerClass = className ? `tag-list ${className}` : 'tag-list';
|
||||
const showQuickAdd = Boolean(onAdd);
|
||||
|
||||
const assignmentItems = useMemo(() => {
|
||||
const map = new Map();
|
||||
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||
const map = new Map<string, SelectionAssignmentMenuItem>();
|
||||
|
||||
normalizedOptions.forEach((option) => {
|
||||
const label = option?.label?.trim();
|
||||
@@ -120,8 +196,10 @@ export const TagSection = ({
|
||||
const payload = { id: tag.id, label, color: tag.color ?? null };
|
||||
if (map.has(key)) {
|
||||
const entry = map.get(key);
|
||||
entry.state = 'all';
|
||||
entry.payload = payload;
|
||||
if (entry) {
|
||||
entry.state = 'all';
|
||||
entry.payload = payload;
|
||||
}
|
||||
return;
|
||||
}
|
||||
map.set(key, {
|
||||
@@ -136,7 +214,7 @@ export const TagSection = ({
|
||||
}, [normalizedOptions, tags]);
|
||||
|
||||
const handleAssignmentSelect = useCallback(
|
||||
(item) => {
|
||||
(item: SelectionAssignmentMenuItem | null) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
@@ -192,7 +270,7 @@ export const TagSection = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const CorrespondentSection = ({
|
||||
export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
|
||||
entries = [],
|
||||
onRemove,
|
||||
onAdd,
|
||||
@@ -203,7 +281,7 @@ export const CorrespondentSection = ({
|
||||
className,
|
||||
}) => {
|
||||
const handleCreate = useCallback(
|
||||
(name) => onAdd?.({ name, input: null }),
|
||||
(name: string) => onAdd?.({ name, input: null }),
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
@@ -211,15 +289,15 @@ export const CorrespondentSection = ({
|
||||
() =>
|
||||
normalizeOptions(datalistOptions)
|
||||
.map((option) => normalizeQuickAddOption(option))
|
||||
.filter(Boolean),
|
||||
.filter((option): option is QuickAddEntry => Boolean(option)),
|
||||
[datalistOptions],
|
||||
);
|
||||
const hasEntries = entries && entries.length > 0;
|
||||
const showQuickAdd = Boolean(onAdd);
|
||||
const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
|
||||
|
||||
const assignmentItems = useMemo(() => {
|
||||
const map = new Map();
|
||||
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||
const map = new Map<string, SelectionAssignmentMenuItem>();
|
||||
|
||||
normalizedOptions.forEach((option) => {
|
||||
const label = option?.label?.trim();
|
||||
@@ -247,8 +325,10 @@ export const CorrespondentSection = ({
|
||||
const payload = { id: entry.id, name: label };
|
||||
if (map.has(key)) {
|
||||
const item = map.get(key);
|
||||
item.state = 'all';
|
||||
item.payload = payload;
|
||||
if (item) {
|
||||
item.state = 'all';
|
||||
item.payload = payload;
|
||||
}
|
||||
return;
|
||||
}
|
||||
map.set(key, {
|
||||
@@ -263,7 +343,7 @@ export const CorrespondentSection = ({
|
||||
}, [normalizedOptions, entries]);
|
||||
|
||||
const handleAssignmentSelect = useCallback(
|
||||
(item) => {
|
||||
(item: SelectionAssignmentMenuItem | null) => {
|
||||
if (!onAdd || !item) {
|
||||
return;
|
||||
}
|
||||
@@ -333,7 +413,7 @@ export const CorrespondentSection = ({
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSummarySection = ({
|
||||
const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
||||
document,
|
||||
tagLookupById = new Map(),
|
||||
tagOptions = [],
|
||||
@@ -387,7 +467,7 @@ const DocumentSummarySection = ({
|
||||
}, [correspondents, document?.correspondents]);
|
||||
|
||||
const metaRows = useMemo(() => {
|
||||
const rows = [];
|
||||
const rows: { key: string; label: string; value: string | null | undefined }[] = [];
|
||||
const currentVersionNumber = document?.current_version?.version_number;
|
||||
if (Number.isFinite(currentVersionNumber)) {
|
||||
rows.push({
|
||||
@@ -433,7 +513,7 @@ const DocumentSummarySection = ({
|
||||
const startTitleEdit = useCallback(() => {
|
||||
if (!editableTitle || !document) return;
|
||||
setIsTitleEditing(true);
|
||||
setTitleDraft(document.title);
|
||||
setTitleDraft(document.title || '');
|
||||
setTitleError(null);
|
||||
}, [document, editableTitle]);
|
||||
|
||||
@@ -445,9 +525,9 @@ const DocumentSummarySection = ({
|
||||
}, []);
|
||||
|
||||
const submitTitleEdit = useCallback(
|
||||
async (event) => {
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!editableTitle || !document) return;
|
||||
if (!editableTitle || !document || !onUpdateTitle) return;
|
||||
const trimmed = titleDraft.trim();
|
||||
if (!trimmed) {
|
||||
setTitleError('Title cannot be empty.');
|
||||
@@ -483,9 +563,9 @@ const DocumentSummarySection = ({
|
||||
}, []);
|
||||
|
||||
const submitIssuedEdit = useCallback(
|
||||
async (event) => {
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!editableIssued || !document) return;
|
||||
if (!editableIssued || !document || !onUpdateIssued) return;
|
||||
const normalizedValue = issuedDraft ? toIssuedTimestamp(issuedDraft, document.issued_at) : null;
|
||||
setIssuedSaving(true);
|
||||
try {
|
||||
@@ -749,15 +829,16 @@ const DocumentSummarySection = ({
|
||||
className="icon-button"
|
||||
onClick={startTitleEdit}
|
||||
aria-label="Edit title"
|
||||
title="Edit title"
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
title="Edit title"
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||
|
||||
<div className="detail-meta">
|
||||
@@ -776,7 +857,6 @@ const DocumentSummarySection = ({
|
||||
</div>
|
||||
|
||||
{renderTags()}
|
||||
|
||||
{renderCorrespondents()}
|
||||
</div>
|
||||
);
|
||||
+67
-13
@@ -1,11 +1,15 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, JSX, MutableRefObject } from 'react';
|
||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
|
||||
const DEFAULT_THUMBNAIL_SIZE = 48;
|
||||
|
||||
// Detect when an element becomes visible within a scroll container so we can delay loading.
|
||||
const useLazyVisibility = (rootRef, resetKey) => {
|
||||
const targetRef = useRef(null);
|
||||
const useLazyVisibility = (
|
||||
rootRef: MutableRefObject<Element | null> | null,
|
||||
resetKey: string | number | null | undefined,
|
||||
) => {
|
||||
const targetRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,7 +28,7 @@ const useLazyVisibility = (rootRef, resetKey) => {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
|
||||
setIsVisible(true);
|
||||
return undefined;
|
||||
}
|
||||
@@ -52,11 +56,54 @@ const useLazyVisibility = (rootRef, resetKey) => {
|
||||
return { ref: targetRef, isVisible };
|
||||
};
|
||||
|
||||
const getPageCount = (doc) =>
|
||||
const getPageCount = (doc: DocumentLike | null | undefined) =>
|
||||
Number.isFinite(doc?.current_version?.metadata?.page_count)
|
||||
? doc.current_version.metadata.page_count
|
||||
? (doc?.current_version?.metadata?.page_count as number)
|
||||
: null;
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface DocumentVersionLike {
|
||||
metadata?: {
|
||||
page_count?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
assets?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier;
|
||||
current_version?: DocumentVersionLike;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AssetLike {
|
||||
id?: Identifier;
|
||||
url?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type EnsureAssetUrl = (
|
||||
documentId: Identifier,
|
||||
asset: AssetLike,
|
||||
options?: { start?: number; limit?: number; [key: string]: unknown },
|
||||
) => Promise<unknown> | void;
|
||||
|
||||
type GetDocumentAsset = (document: DocumentLike | null | undefined, assetType: string) => AssetLike | null | undefined;
|
||||
|
||||
interface DocumentThumbnailImageProps {
|
||||
document?: DocumentLike | null;
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
getDocumentAsset?: GetDocumentAsset;
|
||||
alt?: string;
|
||||
maxSize?: number;
|
||||
scrollRootRef?: MutableRefObject<Element | null> | null;
|
||||
}
|
||||
|
||||
const DocumentThumbnailImage = ({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
@@ -64,12 +111,12 @@ const DocumentThumbnailImage = ({
|
||||
alt = '',
|
||||
maxSize = DEFAULT_THUMBNAIL_SIZE,
|
||||
scrollRootRef = null,
|
||||
}) => {
|
||||
}: DocumentThumbnailImageProps): JSX.Element => {
|
||||
const documentId = document?.id;
|
||||
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId);
|
||||
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
||||
|
||||
const thumbnailAsset = useMemo(
|
||||
const thumbnailAsset = useMemo<AssetLike | null>(
|
||||
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
||||
[document?.current_version],
|
||||
);
|
||||
@@ -89,7 +136,7 @@ const DocumentThumbnailImage = ({
|
||||
};
|
||||
}, [assetWidth, assetHeight, resolvedMaxSize]);
|
||||
|
||||
const innerStyle = useMemo(
|
||||
const innerStyle = useMemo<CSSProperties>(
|
||||
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
||||
[dimensions.height, dimensions.width],
|
||||
);
|
||||
@@ -98,10 +145,17 @@ const DocumentThumbnailImage = ({
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
return resolveDocumentAssetUrl(document, 'thumbnail', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
const options: {
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
getAsset?: GetDocumentAsset;
|
||||
} = {};
|
||||
if (ensureAssetUrl) {
|
||||
options.ensureAssetUrl = ensureAssetUrl;
|
||||
}
|
||||
if (getDocumentAsset) {
|
||||
options.getAsset = getDocumentAsset;
|
||||
}
|
||||
return resolveDocumentAssetUrl(document, 'thumbnail', options);
|
||||
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
|
||||
|
||||
const pageCount = getPageCount(document);
|
||||
+80
-1
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { DragEvent, MouseEvent, RefObject } from 'react';
|
||||
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
||||
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
||||
import CorrespondentLinks from './CorrespondentLinks';
|
||||
@@ -7,7 +8,85 @@ import { resolveCorrespondents } from './correspondents';
|
||||
import { writeTagTransferData } from './tagTransfer';
|
||||
import useInlineRename from './useInlineRename';
|
||||
|
||||
const DocumentsGrid = ({
|
||||
export type Identifier = string | number;
|
||||
|
||||
export interface FolderLike {
|
||||
id?: Identifier | 'root';
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface DocumentTag {
|
||||
id?: Identifier;
|
||||
label?: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentCorrespondent {
|
||||
id?: Identifier;
|
||||
name?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface DocumentLike {
|
||||
id?: Identifier;
|
||||
title?: string;
|
||||
tags?: DocumentTag[] | null;
|
||||
correspondents?: DocumentCorrespondent[] | null;
|
||||
}
|
||||
|
||||
export type FolderEntry = {
|
||||
type: 'folder';
|
||||
id: Identifier | 'root';
|
||||
key: string;
|
||||
folder: FolderLike;
|
||||
};
|
||||
|
||||
export type DocumentEntry = {
|
||||
type: 'document';
|
||||
id: Identifier;
|
||||
key: string;
|
||||
document: DocumentLike;
|
||||
};
|
||||
|
||||
export type DocumentsGridEntry = FolderEntry | DocumentEntry;
|
||||
|
||||
type FolderEventHandler = (folder: FolderLike, event: MouseEvent<HTMLDivElement>) => void;
|
||||
type DocumentEventHandler = (document: DocumentLike, event: MouseEvent<HTMLDivElement>) => void;
|
||||
|
||||
interface DocumentsGridProps {
|
||||
entries: DocumentsGridEntry[];
|
||||
selectedDocumentIdsSet?: Set<Identifier> | null;
|
||||
selectedFolderIdsSet?: Set<Identifier | 'root'> | null;
|
||||
draggingDocumentIdsSet?: Set<Identifier> | null;
|
||||
draggedFolderId?: Identifier | 'root' | null;
|
||||
onFolderClick?: FolderEventHandler;
|
||||
onFolderSelect?: (folderId: Identifier | 'root') => void;
|
||||
onFolderDragOver?: (event: DragEvent<HTMLDivElement>, folderId: Identifier | 'root') => void;
|
||||
onFolderDragLeave?: (event: DragEvent<HTMLDivElement>) => void;
|
||||
onFolderDrop?: (event: DragEvent<HTMLDivElement>, folderId: Identifier | 'root') => void;
|
||||
onFolderDragStart?: (event: DragEvent<HTMLDivElement>, folderId: Identifier | 'root') => void;
|
||||
onFolderDragEnd?: (event: DragEvent<HTMLDivElement>) => void;
|
||||
onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
|
||||
onDocumentClick?: DocumentEventHandler;
|
||||
onDocumentActivate?: DocumentEventHandler;
|
||||
onDocumentDragStart?: (event: DragEvent<HTMLDivElement>, document: DocumentLike) => void;
|
||||
onDocumentDragEnd?: (event: DragEvent<HTMLDivElement>) => void;
|
||||
onDocumentTagDragOver?: (event: DragEvent<HTMLDivElement>) => void;
|
||||
onDocumentTagDragLeave?: (event: DragEvent<HTMLDivElement>) => void;
|
||||
onDocumentTagDrop?: (event: DragEvent<HTMLDivElement>, documentId: Identifier) => void;
|
||||
ensureAssetUrl?: (...args: any[]) => unknown;
|
||||
getDocumentAsset?: (...args: any[]) => unknown;
|
||||
gridIconSize?: number;
|
||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||
onTagClick?: (tagId?: Identifier | null) => void;
|
||||
scrollRef?: RefObject<HTMLElement | null>;
|
||||
onCorrespondentClick?: (correspondentId?: Identifier | null) => void;
|
||||
activeCorrespondentIdSet?: Set<Identifier | null | undefined> | null;
|
||||
onClearSelection?: () => void;
|
||||
onDocumentRename?: (docId: Identifier, title: string) => Promise<boolean> | boolean;
|
||||
}
|
||||
|
||||
const DocumentsGrid: React.FC<DocumentsGridProps> = ({
|
||||
entries,
|
||||
selectedDocumentIdsSet,
|
||||
selectedFolderIdsSet,
|
||||
+84
-1
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { DragEvent, MouseEvent, RefObject } from 'react';
|
||||
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { formatDate } from '../utils/date';
|
||||
@@ -8,7 +9,89 @@ import { resolveCorrespondents } from './correspondents';
|
||||
import { writeTagTransferData } from './tagTransfer';
|
||||
import useInlineRename from './useInlineRename';
|
||||
|
||||
const DocumentsList = ({
|
||||
export type Identifier = string | number;
|
||||
|
||||
export interface FolderLike {
|
||||
id?: Identifier | 'root';
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface DocumentTag {
|
||||
id?: Identifier;
|
||||
label?: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentCorrespondent {
|
||||
id?: Identifier;
|
||||
name?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface DocumentLike {
|
||||
id?: Identifier;
|
||||
title?: string;
|
||||
issued_at?: string | null;
|
||||
created_at?: string | null;
|
||||
uploaded_at?: string | null;
|
||||
tags?: DocumentTag[] | null;
|
||||
correspondents?: DocumentCorrespondent[] | null;
|
||||
}
|
||||
|
||||
export type FolderEntry = {
|
||||
type: 'folder';
|
||||
id: Identifier | 'root';
|
||||
key: string;
|
||||
folder: FolderLike;
|
||||
};
|
||||
|
||||
export type DocumentEntry = {
|
||||
type: 'document';
|
||||
id: Identifier;
|
||||
key: string;
|
||||
document: DocumentLike;
|
||||
};
|
||||
|
||||
export type DocumentsListEntry = FolderEntry | DocumentEntry;
|
||||
|
||||
export type FolderEventHandler = (folder: FolderLike, event: MouseEvent<HTMLTableRowElement>) => void;
|
||||
export type DocumentEventHandler = (document: DocumentLike, event: MouseEvent<HTMLTableRowElement>) => void;
|
||||
|
||||
export interface DocumentsListProps {
|
||||
entries: DocumentsListEntry[];
|
||||
focusedRowKey?: string | null;
|
||||
selectedDocumentIdsSet?: Set<Identifier> | null;
|
||||
selectedFolderIdsSet?: Set<Identifier | 'root'> | null;
|
||||
draggingDocumentIdsSet?: Set<Identifier> | null;
|
||||
draggedFolderId?: Identifier | 'root' | null;
|
||||
ensureAssetUrl?: (...args: any[]) => unknown;
|
||||
getDocumentAsset?: (...args: any[]) => unknown;
|
||||
onFolderClick?: FolderEventHandler;
|
||||
onFolderSelect?: (folderId: Identifier | 'root') => void;
|
||||
onFolderDragOver?: (event: DragEvent<HTMLTableRowElement>, folderId: Identifier | 'root') => void;
|
||||
onFolderDragLeave?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||
onFolderDrop?: (event: DragEvent<HTMLTableRowElement>, folderId: Identifier | 'root') => void;
|
||||
onFolderDragStart?: (event: DragEvent<HTMLTableRowElement>, folderId: Identifier | 'root') => void;
|
||||
onFolderDragEnd?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||
onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
|
||||
onDocumentClick?: DocumentEventHandler;
|
||||
onDocumentActivate?: DocumentEventHandler;
|
||||
onDocumentDragStart?: (event: DragEvent<HTMLTableRowElement>, document: DocumentLike) => void;
|
||||
onDocumentDragEnd?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||
onDocumentTagDragOver?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||
onDocumentTagDragLeave?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||
onDocumentTagDrop?: (event: DragEvent<HTMLTableRowElement>, documentId: Identifier) => void;
|
||||
onDocumentRename?: (documentId: Identifier, nextTitle: string) => Promise<boolean> | boolean;
|
||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||
onTagClick?: (tagId?: Identifier | null) => void;
|
||||
onCorrespondentClick?: (correspondentId?: Identifier | null) => void;
|
||||
activeCorrespondentIdSet?: Set<Identifier | null | undefined> | null;
|
||||
scrollRef?: RefObject<HTMLElement | null>;
|
||||
onClearSelection?: () => void;
|
||||
}
|
||||
|
||||
|
||||
const DocumentsList: React.FC<DocumentsListProps> = ({
|
||||
entries,
|
||||
focusedRowKey,
|
||||
selectedDocumentIdsSet,
|
||||
+65
-12
@@ -1,16 +1,57 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useFloatingMenu from '../ui/useFloatingMenu';
|
||||
import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../ui/icons';
|
||||
|
||||
const STATE_ORDER = {
|
||||
export type AssignmentState = 'all' | 'partial' | 'none';
|
||||
|
||||
export interface SelectionAssignmentMenuItem {
|
||||
id?: string | number;
|
||||
label?: string;
|
||||
state?: AssignmentState;
|
||||
count?: number | null;
|
||||
total?: number | null;
|
||||
color?: string | null;
|
||||
value?: string | number;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export interface NormalizedSelectionAssignmentItem {
|
||||
id: string | number;
|
||||
label: string;
|
||||
state: AssignmentState;
|
||||
count: number | null;
|
||||
total: number | null;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface SelectionAssignmentMenuProps {
|
||||
label: React.ReactNode;
|
||||
items?: SelectionAssignmentMenuItem[];
|
||||
placeholder?: string;
|
||||
emptyMessage?: string;
|
||||
createLabel?: string | null;
|
||||
onToggle?: (item: NormalizedSelectionAssignmentItem) => Promise<void> | void;
|
||||
onCreate?: (value: string) => Promise<void> | void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
triggerContent?: React.ReactNode;
|
||||
triggerClassName?: string;
|
||||
showStateIndicators?: boolean;
|
||||
showCounts?: boolean;
|
||||
onOpenMenu?: () => void;
|
||||
renderItemLabel?: (item: NormalizedSelectionAssignmentItem) => React.ReactNode;
|
||||
positionStrategy?: 'absolute' | 'fixed';
|
||||
}
|
||||
|
||||
const STATE_ORDER: Record<AssignmentState, number> = {
|
||||
all: 0,
|
||||
partial: 1,
|
||||
none: 2,
|
||||
};
|
||||
|
||||
const normalizeItems = (items) =>
|
||||
const normalizeItems = (items?: SelectionAssignmentMenuItem[]): NormalizedSelectionAssignmentItem[] =>
|
||||
(Array.isArray(items) ? items : [])
|
||||
.map((item) => {
|
||||
.map<NormalizedSelectionAssignmentItem | null>((item) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
@@ -18,18 +59,23 @@ const normalizeItems = (items) =>
|
||||
if (!trimmedLabel) {
|
||||
return null;
|
||||
}
|
||||
const state: AssignmentState = item.state === 'all'
|
||||
? 'all'
|
||||
: item.state === 'partial'
|
||||
? 'partial'
|
||||
: 'none';
|
||||
return {
|
||||
id: item.id ?? trimmedLabel,
|
||||
label: trimmedLabel,
|
||||
state: item.state === 'all' ? 'all' : item.state === 'partial' ? 'partial' : 'none',
|
||||
state,
|
||||
count: typeof item.count === 'number' ? item.count : null,
|
||||
total: typeof item.total === 'number' ? item.total : null,
|
||||
payload: item.payload ?? item,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
.filter((item): item is NormalizedSelectionAssignmentItem => Boolean(item));
|
||||
|
||||
const SelectionAssignmentMenu = ({
|
||||
const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
|
||||
label,
|
||||
items = [],
|
||||
placeholder = 'Search…',
|
||||
@@ -47,8 +93,8 @@ const SelectionAssignmentMenu = ({
|
||||
renderItemLabel = null,
|
||||
positionStrategy = 'absolute',
|
||||
}) => {
|
||||
const anchorRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
@@ -64,7 +110,14 @@ const SelectionAssignmentMenu = ({
|
||||
align: 'center',
|
||||
positionStrategy,
|
||||
minWidth: 220,
|
||||
});
|
||||
}) as {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
close: () => void;
|
||||
menuRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
menuStyle: CSSProperties | null;
|
||||
updatePosition: () => void;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled && isOpen) {
|
||||
@@ -106,7 +159,7 @@ const SelectionAssignmentMenu = ({
|
||||
}, [normalizedItems, query]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (item) => {
|
||||
async (item: NormalizedSelectionAssignmentItem) => {
|
||||
if (!item || typeof onToggle !== 'function') {
|
||||
return;
|
||||
}
|
||||
@@ -124,7 +177,7 @@ const SelectionAssignmentMenu = ({
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (event) => {
|
||||
async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault?.();
|
||||
if (typeof onCreate !== 'function') {
|
||||
return;
|
||||
+170
-70
@@ -1,10 +1,4 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
TrashIcon,
|
||||
AnalyzeIcon,
|
||||
@@ -14,19 +8,90 @@ import {
|
||||
CorrespondentIcon,
|
||||
LoaderIcon,
|
||||
} from '../ui/icons';
|
||||
import SelectionAssignmentMenu from './SelectionAssignmentMenu';
|
||||
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
|
||||
import SelectionSummary from './SelectionSummary';
|
||||
import { api, useAppState } from '../app/appState';
|
||||
|
||||
const normalizeDocumentList = (selectedDocumentIds) =>
|
||||
Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : [];
|
||||
|
||||
const ROOT_FOLDER_LABEL = 'Documents';
|
||||
|
||||
const buildFolderTreeOptions = (tree) => {
|
||||
const entries = [];
|
||||
type DocumentId = string | number;
|
||||
type NullableDocumentId = DocumentId | null | undefined;
|
||||
|
||||
const traverse = (nodes, parentSegments) => {
|
||||
type SelectedIdList = Array<NullableDocumentId> | null | undefined;
|
||||
|
||||
type FolderTreeNode = {
|
||||
id?: DocumentId;
|
||||
name?: string;
|
||||
label?: string;
|
||||
value?: DocumentId;
|
||||
children?: FolderTreeNode[];
|
||||
};
|
||||
|
||||
interface TagOption {
|
||||
id?: DocumentId;
|
||||
label?: string;
|
||||
name?: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface CorrespondentOption {
|
||||
id?: DocumentId;
|
||||
name?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface DocumentLike {
|
||||
id?: DocumentId;
|
||||
tags?: TagOption[];
|
||||
correspondents?: CorrespondentOption[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface BulkTagMutationArgs {
|
||||
label: string;
|
||||
input: unknown;
|
||||
documentIds: DocumentId[];
|
||||
}
|
||||
|
||||
interface BulkCorrespondentAddArgs {
|
||||
name: string;
|
||||
input: unknown;
|
||||
documentIds: DocumentId[];
|
||||
}
|
||||
|
||||
interface BulkCorrespondentRemoveArgs {
|
||||
assignments: Array<{ correspondent_id: DocumentId }>;
|
||||
documentIds: DocumentId[];
|
||||
}
|
||||
|
||||
interface SelectionFloatingActionsProps {
|
||||
selectionCount?: number;
|
||||
selectedDocumentIds?: SelectedIdList;
|
||||
selectedFolderIds?: SelectedIdList;
|
||||
documentLookup?: Map<DocumentId, DocumentLike> | null;
|
||||
tags?: TagOption[] | null;
|
||||
tagLookupById?: Map<DocumentId, TagOption> | null;
|
||||
correspondents?: CorrespondentOption[] | null;
|
||||
folderOptions?: SelectionAssignmentMenuItem[] | null;
|
||||
onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
||||
onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
||||
onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise<void> | void;
|
||||
onBulkCorrespondentRemove?: (args: BulkCorrespondentRemoveArgs) => Promise<void> | void;
|
||||
onBulkReanalyze?: (documentIds: DocumentId[]) => Promise<void> | void;
|
||||
onDeleteSelection?: () => void;
|
||||
onClearSelection?: () => void;
|
||||
onMoveDocumentsToFolder?: (documentIds: DocumentId[], folderId: DocumentId) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const normalizeDocumentList = (selectedIds?: SelectedIdList): DocumentId[] =>
|
||||
Array.isArray(selectedIds)
|
||||
? selectedIds.filter((value): value is DocumentId => value !== null && value !== undefined)
|
||||
: [];
|
||||
|
||||
const buildFolderTreeOptions = (tree?: FolderTreeNode[] | null): SelectionAssignmentMenuItem[] => {
|
||||
const entries: SelectionAssignmentMenuItem[] = [];
|
||||
|
||||
const traverse = (nodes: FolderTreeNode[] | undefined | null, parentSegments: string[]) => {
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -34,11 +99,21 @@ const buildFolderTreeOptions = (tree) => {
|
||||
if (!node || !node.id) {
|
||||
return;
|
||||
}
|
||||
const trimmedName = node?.name?.trim?.();
|
||||
const trimmedName = node.name?.trim?.();
|
||||
const name = trimmedName?.length ? trimmedName : 'Folder';
|
||||
const nextSegments = parentSegments.concat([name]);
|
||||
const label = nextSegments.join('/');
|
||||
entries.push({ id: node.id, label });
|
||||
entries.push({
|
||||
id: node.id,
|
||||
label,
|
||||
state: 'none',
|
||||
payload: {
|
||||
id: node.id,
|
||||
label,
|
||||
segments: nextSegments,
|
||||
depth: Math.max(nextSegments.length - 1, 0),
|
||||
},
|
||||
});
|
||||
if (Array.isArray(node.children) && node.children.length) {
|
||||
traverse(node.children, nextSegments);
|
||||
}
|
||||
@@ -47,19 +122,30 @@ const buildFolderTreeOptions = (tree) => {
|
||||
|
||||
traverse(Array.isArray(tree) ? tree : [], [ROOT_FOLDER_LABEL]);
|
||||
|
||||
entries.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
||||
entries.sort((a, b) => (a.label || '').localeCompare(b.label || '', undefined, { sensitivity: 'base' }));
|
||||
|
||||
return [{ id: 'root', label: ROOT_FOLDER_LABEL }, ...entries];
|
||||
return [{ id: 'root', label: ROOT_FOLDER_LABEL, state: 'none', payload: { id: 'root' } }, ...entries];
|
||||
};
|
||||
|
||||
const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
|
||||
const buildTagAssignments = (
|
||||
selectedDocuments: DocumentLike[],
|
||||
tagLookupById: Map<DocumentId, TagOption> | null | undefined,
|
||||
tags: TagOption[] | null | undefined,
|
||||
total: number,
|
||||
): SelectionAssignmentMenuItem[] => {
|
||||
if (!total) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const map = new Map();
|
||||
const map = new Map<string | number, {
|
||||
id?: DocumentId;
|
||||
label: string;
|
||||
color: string | null;
|
||||
count: number;
|
||||
total: number;
|
||||
}>();
|
||||
|
||||
const ensureEntry = (id, label, color = null) => {
|
||||
const ensureEntry = (id?: DocumentId, label?: string, color: string | null = null) => {
|
||||
const key = id ?? label;
|
||||
if (!key || !label) {
|
||||
return null;
|
||||
@@ -73,7 +159,7 @@ const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
|
||||
total,
|
||||
});
|
||||
}
|
||||
return map.get(key);
|
||||
return map.get(key) ?? null;
|
||||
};
|
||||
|
||||
selectedDocuments.forEach((doc) => {
|
||||
@@ -106,14 +192,23 @@ const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
|
||||
});
|
||||
};
|
||||
|
||||
const buildCorrespondentAssignments = (selectedDocuments, correspondents, total) => {
|
||||
const buildCorrespondentAssignments = (
|
||||
selectedDocuments: DocumentLike[],
|
||||
correspondents: CorrespondentOption[] | null | undefined,
|
||||
total: number,
|
||||
): SelectionAssignmentMenuItem[] => {
|
||||
if (!total) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const map = new Map();
|
||||
const map = new Map<string | number, {
|
||||
id?: DocumentId;
|
||||
label: string;
|
||||
count: number;
|
||||
total: number;
|
||||
}>();
|
||||
|
||||
const ensureEntry = (id, name) => {
|
||||
const ensureEntry = (id?: DocumentId, name?: string) => {
|
||||
const key = id ?? name;
|
||||
if (!key || !name) {
|
||||
return null;
|
||||
@@ -126,7 +221,7 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total)
|
||||
total,
|
||||
});
|
||||
}
|
||||
return map.get(key);
|
||||
return map.get(key) ?? null;
|
||||
};
|
||||
|
||||
selectedDocuments.forEach((doc) => {
|
||||
@@ -139,7 +234,7 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total)
|
||||
});
|
||||
|
||||
(correspondents || []).forEach((entry) => {
|
||||
ensureEntry(entry?.id, entry?.name);
|
||||
ensureEntry(entry?.id, entry?.name || entry?.label);
|
||||
});
|
||||
|
||||
return Array.from(map.values()).map((entry) => {
|
||||
@@ -156,14 +251,14 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total)
|
||||
});
|
||||
};
|
||||
|
||||
const SelectionFloatingActions = ({
|
||||
const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
selectionCount = 0,
|
||||
selectedDocumentIds,
|
||||
selectedDocumentIds = [],
|
||||
selectedFolderIds = [],
|
||||
documentLookup,
|
||||
tags,
|
||||
tags = [],
|
||||
tagLookupById,
|
||||
correspondents,
|
||||
correspondents = [],
|
||||
folderOptions = [],
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
@@ -174,12 +269,17 @@ const SelectionFloatingActions = ({
|
||||
onClearSelection = null,
|
||||
onMoveDocumentsToFolder,
|
||||
}) => {
|
||||
const { token, tenant } = useAppState();
|
||||
const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId } | null };
|
||||
const tenantId = tenant?.id ?? null;
|
||||
|
||||
const [remoteFolderOptions, setRemoteFolderOptions] = useState(null);
|
||||
const documentLookupMap = useMemo(() => (
|
||||
documentLookup instanceof Map ? documentLookup : new Map<DocumentId, DocumentLike>()
|
||||
), [documentLookup]);
|
||||
const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null;
|
||||
|
||||
const [remoteFolderOptions, setRemoteFolderOptions] = useState<SelectionAssignmentMenuItem[] | null>(null);
|
||||
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||
const folderTreeFetchRef = useRef(null);
|
||||
const folderTreeFetchRef = useRef<Promise<SelectionAssignmentMenuItem[]> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRemoteFolderOptions(null);
|
||||
@@ -187,7 +287,7 @@ const SelectionFloatingActions = ({
|
||||
setLoadingFolders(false);
|
||||
}, [tenantId, token]);
|
||||
|
||||
const requestFolderTree = useCallback(async () => {
|
||||
const requestFolderTree = useCallback(async (): Promise<SelectionAssignmentMenuItem[]> => {
|
||||
if (!token) {
|
||||
setRemoteFolderOptions([]);
|
||||
return [];
|
||||
@@ -226,59 +326,55 @@ const SelectionFloatingActions = ({
|
||||
requestFolderTree();
|
||||
}, [requestFolderTree]);
|
||||
|
||||
const effectiveFolderOptions = useMemo(() => {
|
||||
const effectiveFolderOptions = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||
if (remoteFolderOptions !== null) {
|
||||
return remoteFolderOptions;
|
||||
}
|
||||
return Array.isArray(folderOptions) ? folderOptions : [];
|
||||
}, [remoteFolderOptions, folderOptions]);
|
||||
|
||||
const documentIdList = useMemo(
|
||||
const documentIdList = useMemo<DocumentId[]>(
|
||||
() => normalizeDocumentList(selectedDocumentIds),
|
||||
[selectedDocumentIds],
|
||||
);
|
||||
|
||||
const folderIdList = useMemo(
|
||||
const folderIdList = useMemo<DocumentId[]>(
|
||||
() => normalizeDocumentList(selectedFolderIds),
|
||||
[selectedFolderIds],
|
||||
);
|
||||
|
||||
const documentCount = documentIdList.length;
|
||||
const folderCount = folderIdList.length;
|
||||
const totalCount = typeof selectionCount === 'number'
|
||||
? selectionCount
|
||||
: documentCount + folderCount;
|
||||
const totalCount = typeof selectionCount === 'number' ? selectionCount : documentCount + folderCount;
|
||||
|
||||
const selectedDocuments = useMemo(() => {
|
||||
if (!documentIdList.length || !(documentLookup instanceof Map)) {
|
||||
const selectedDocuments = useMemo<DocumentLike[]>(() => {
|
||||
if (!documentIdList.length || !(documentLookupMap instanceof Map)) {
|
||||
return [];
|
||||
}
|
||||
return documentIdList
|
||||
.map((id) => documentLookup.get(id))
|
||||
.filter(Boolean);
|
||||
}, [documentIdList, documentLookup]);
|
||||
.map((id) => documentLookupMap.get(id))
|
||||
.filter((doc): doc is DocumentLike => Boolean(doc));
|
||||
}, [documentIdList, documentLookupMap]);
|
||||
|
||||
const selectedDocCount = selectedDocuments.length;
|
||||
|
||||
const moveAssignments = useMemo(() => {
|
||||
const moveAssignments = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||
if (!Array.isArray(effectiveFolderOptions)) {
|
||||
return [];
|
||||
}
|
||||
return effectiveFolderOptions
|
||||
.map((option) => {
|
||||
const id = option?.id ?? option?.value ?? option;
|
||||
.map<SelectionAssignmentMenuItem | null>((option) => {
|
||||
const id = (option?.id ?? option?.payload?.id ?? option?.value) as DocumentId | undefined;
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const label = option?.label || option?.name || String(id);
|
||||
const segments = label.split('/');
|
||||
const label = option?.label || option?.payload?.label || option?.name || String(id);
|
||||
const segments = typeof label === 'string' ? label.split('/') : [label];
|
||||
const depth = Math.max(segments.length - 1, 0);
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
state: 'none',
|
||||
count: null,
|
||||
total: null,
|
||||
payload: {
|
||||
id,
|
||||
label,
|
||||
@@ -287,12 +383,12 @@ const SelectionFloatingActions = ({
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
.filter((entry): entry is SelectionAssignmentMenuItem => Boolean(entry));
|
||||
}, [effectiveFolderOptions]);
|
||||
|
||||
const tagAssignments = useMemo(
|
||||
() => buildTagAssignments(selectedDocuments, tagLookupById, tags, selectedDocCount),
|
||||
[selectedDocuments, tagLookupById, tags, selectedDocCount],
|
||||
() => buildTagAssignments(selectedDocuments, tagLookupMap, tags, selectedDocCount),
|
||||
[selectedDocuments, tagLookupMap, tags, selectedDocCount],
|
||||
);
|
||||
|
||||
const correspondentAssignments = useMemo(
|
||||
@@ -300,9 +396,10 @@ const SelectionFloatingActions = ({
|
||||
[selectedDocuments, correspondents, selectedDocCount],
|
||||
);
|
||||
|
||||
const renderFolderLabel = useCallback((item) => {
|
||||
const segments = item?.payload?.segments || (item?.label ? item.label.split('/') : []);
|
||||
const depth = item?.payload?.depth ?? Math.max(segments.length - 1, 0);
|
||||
const renderFolderLabel = useCallback((item: SelectionAssignmentMenuItem) => {
|
||||
const payload = (item?.payload as { segments?: string[]; depth?: number }) || {};
|
||||
const segments = payload.segments || (typeof item.label === 'string' ? item.label.split('/') : []);
|
||||
const depth = payload.depth ?? Math.max(segments.length - 1, 0);
|
||||
const clampedDepth = Math.min(depth, 6);
|
||||
const indentWidth = clampedDepth > 0 ? clampedDepth * 0.9 : 0;
|
||||
const name = segments.length ? segments[segments.length - 1] : item?.label || 'Folder';
|
||||
@@ -328,21 +425,21 @@ const SelectionFloatingActions = ({
|
||||
}, []);
|
||||
|
||||
const handleToggleTagAssignment = useCallback(
|
||||
async (item) => {
|
||||
async (item: SelectionAssignmentMenuItem) => {
|
||||
if (!selectedDocCount || !item) {
|
||||
return;
|
||||
}
|
||||
if (item.state === 'all') {
|
||||
await onBulkTagRemove?.({ label: item.label, input: null, documentIds: documentIdList });
|
||||
await onBulkTagRemove?.({ label: item.label || '', input: null, documentIds: documentIdList });
|
||||
} else {
|
||||
await onBulkTagAdd?.({ label: item.label, input: null, documentIds: documentIdList });
|
||||
await onBulkTagAdd?.({ label: item.label || '', input: null, documentIds: documentIdList });
|
||||
}
|
||||
},
|
||||
[selectedDocCount, onBulkTagAdd, onBulkTagRemove, documentIdList],
|
||||
);
|
||||
|
||||
const handleCreateTagAssignment = useCallback(
|
||||
async (label) => {
|
||||
async (label: string) => {
|
||||
if (!selectedDocCount || !label) {
|
||||
return;
|
||||
}
|
||||
@@ -352,7 +449,7 @@ const SelectionFloatingActions = ({
|
||||
);
|
||||
|
||||
const handleToggleCorrespondentAssignment = useCallback(
|
||||
async (item) => {
|
||||
async (item: SelectionAssignmentMenuItem) => {
|
||||
if (!selectedDocCount || !item) {
|
||||
return;
|
||||
}
|
||||
@@ -365,14 +462,14 @@ const SelectionFloatingActions = ({
|
||||
documentIds: documentIdList,
|
||||
});
|
||||
} else {
|
||||
await onBulkCorrespondentAdd?.({ name: item.label, input: null, documentIds: documentIdList });
|
||||
await onBulkCorrespondentAdd?.({ name: item.label || '', input: null, documentIds: documentIdList });
|
||||
}
|
||||
},
|
||||
[selectedDocCount, onBulkCorrespondentAdd, onBulkCorrespondentRemove, documentIdList],
|
||||
);
|
||||
|
||||
const handleCreateCorrespondentAssignment = useCallback(
|
||||
async (name) => {
|
||||
async (name: string) => {
|
||||
if (!selectedDocCount || !name) {
|
||||
return;
|
||||
}
|
||||
@@ -382,15 +479,18 @@ const SelectionFloatingActions = ({
|
||||
);
|
||||
|
||||
const handleMoveSelectionToFolder = useCallback(
|
||||
async (option) => {
|
||||
async (option: unknown) => {
|
||||
if (!documentIdList.length || typeof onMoveDocumentsToFolder !== 'function') {
|
||||
return;
|
||||
}
|
||||
const value = option?.id ?? option?.value ?? option;
|
||||
if (!value) {
|
||||
const candidate = option as { id?: DocumentId; value?: DocumentId } | DocumentId | null | undefined;
|
||||
const value = typeof candidate === 'object'
|
||||
? candidate?.id ?? candidate?.value ?? null
|
||||
: candidate;
|
||||
if (!value && value !== 0) {
|
||||
return;
|
||||
}
|
||||
await onMoveDocumentsToFolder(documentIdList, value);
|
||||
await onMoveDocumentsToFolder(documentIdList, value as DocumentId);
|
||||
},
|
||||
[documentIdList, onMoveDocumentsToFolder],
|
||||
);
|
||||
+7
-1
@@ -1,7 +1,13 @@
|
||||
import React from 'react';
|
||||
import { FileIcon, FolderOutlineIcon } from '../ui/icons';
|
||||
|
||||
const SelectionSummary = ({ documentCount = 0, folderCount = 0, totalCount = 0 }) => {
|
||||
interface SelectionSummaryProps {
|
||||
documentCount?: number;
|
||||
folderCount?: number;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
const SelectionSummary: React.FC<SelectionSummaryProps> = ({ documentCount = 0, folderCount = 0, totalCount = 0 }) => {
|
||||
const docCount = Number(documentCount) || 0;
|
||||
const folderCountNumber = Number(folderCount) || 0;
|
||||
const aggregateCount = docCount + folderCountNumber;
|
||||
@@ -1,34 +0,0 @@
|
||||
export const resolveCorrespondents = (doc) => {
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
|
||||
doc.correspondents.forEach((entry = {}, index) => {
|
||||
const { id, name } = entry;
|
||||
const trimmedName = name?.trim?.();
|
||||
if (!trimmedName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id && seen.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
seen.add(id);
|
||||
}
|
||||
|
||||
results.push({
|
||||
id,
|
||||
name: trimmedName,
|
||||
key: id ?? `${trimmedName}-${index}`,
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export default resolveCorrespondents;
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface CorrespondentReference {
|
||||
id?: string | number | null;
|
||||
name?: string | null;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export interface DocumentLike {
|
||||
correspondents?: CorrespondentReference[];
|
||||
}
|
||||
|
||||
export interface ResolvedCorrespondent {
|
||||
id?: string | number | null;
|
||||
name: string;
|
||||
key: string | number;
|
||||
}
|
||||
|
||||
export const resolveCorrespondents = (doc?: DocumentLike | null): ResolvedCorrespondent[] => {
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string | number>();
|
||||
const results: ResolvedCorrespondent[] = [];
|
||||
|
||||
doc.correspondents.forEach((entry = {}, index) => {
|
||||
const { id, name } = entry;
|
||||
const trimmedName = name?.trim?.();
|
||||
if (!trimmedName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id != null && seen.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id != null) {
|
||||
seen.add(id);
|
||||
}
|
||||
|
||||
results.push({
|
||||
id,
|
||||
name: trimmedName,
|
||||
key: id ?? `${trimmedName}-${index}`,
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export default resolveCorrespondents;
|
||||
+32
-10
@@ -1,9 +1,23 @@
|
||||
import { openOcrTextInNewTab } from '../utils/ocr';
|
||||
|
||||
type ResolveApiPath = (path: string) => string;
|
||||
type EnsurePreviewData = (id: string | number) => Promise<void>;
|
||||
type EnsureAssetUrl = (id: string | number, asset: unknown, options?: unknown) => Promise<unknown>;
|
||||
type GetDocumentAsset = (document: DocumentLike, type: string) => unknown;
|
||||
|
||||
interface DocumentVersion {
|
||||
download_path?: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentLike {
|
||||
id?: string | number;
|
||||
current_version?: DocumentVersion | null;
|
||||
}
|
||||
|
||||
const asyncFalse = async () => false;
|
||||
|
||||
const resolveDocumentDownloadHref = (document, resolveApiPath) => {
|
||||
if (!document || typeof resolveApiPath !== 'function') {
|
||||
const resolveDocumentDownloadHref = (document: DocumentLike | null | undefined, resolveApiPath?: ResolveApiPath | null): string | null => {
|
||||
if (!document || !resolveApiPath) {
|
||||
return null;
|
||||
}
|
||||
const downloadPath = document.current_version?.download_path;
|
||||
@@ -13,13 +27,23 @@ const resolveDocumentDownloadHref = (document, resolveApiPath) => {
|
||||
return resolveApiPath(downloadPath);
|
||||
};
|
||||
|
||||
const hasDocumentOcrAsset = (document, getDocumentAsset) => {
|
||||
if (!document || typeof getDocumentAsset !== 'function') {
|
||||
const hasDocumentOcrAsset = (document: DocumentLike | null | undefined, getDocumentAsset?: GetDocumentAsset | null): boolean => {
|
||||
if (!document || !getDocumentAsset) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(getDocumentAsset(document, 'ocr-text'));
|
||||
};
|
||||
|
||||
interface CreateDocumentActionStateArgs {
|
||||
document: DocumentLike | null;
|
||||
resolveApiPath?: ResolveApiPath | null;
|
||||
ensurePreviewData: EnsurePreviewData;
|
||||
ensureAssetUrl: EnsureAssetUrl;
|
||||
getDocumentAsset?: GetDocumentAsset | null;
|
||||
notifyApiError?: (error: unknown, message: string) => void;
|
||||
ocrErrorMessage?: string;
|
||||
}
|
||||
|
||||
export const createDocumentActionState = ({
|
||||
document,
|
||||
resolveApiPath,
|
||||
@@ -28,7 +52,7 @@ export const createDocumentActionState = ({
|
||||
getDocumentAsset,
|
||||
notifyApiError,
|
||||
ocrErrorMessage = 'Unable to open OCR text.',
|
||||
}) => {
|
||||
}: CreateDocumentActionStateArgs) => {
|
||||
if (!document) {
|
||||
return {
|
||||
downloadHref: null,
|
||||
@@ -49,14 +73,12 @@ export const createDocumentActionState = ({
|
||||
getDocumentAsset,
|
||||
ensureAssetUrl,
|
||||
});
|
||||
if (!success && typeof notifyApiError === 'function') {
|
||||
notifyApiError(new Error('OCR text URL unavailable.'), ocrErrorMessage);
|
||||
if (!success) {
|
||||
notifyApiError?.(new Error('OCR text URL unavailable.'), ocrErrorMessage);
|
||||
}
|
||||
return success;
|
||||
} catch (error) {
|
||||
if (typeof notifyApiError === 'function') {
|
||||
notifyApiError(error, ocrErrorMessage);
|
||||
}
|
||||
notifyApiError?.(error, ocrErrorMessage);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+26
-3
@@ -1,6 +1,29 @@
|
||||
import { formatDateTime } from '../utils/date';
|
||||
|
||||
export const buildDocumentMetadataItems = (document) => {
|
||||
interface DocumentVersionMetadata {
|
||||
checksum?: string | null;
|
||||
}
|
||||
|
||||
interface DocumentMetadata {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DocumentLike {
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
filename?: string | null;
|
||||
original_name?: string | null;
|
||||
content_type?: string | null;
|
||||
metadata?: DocumentMetadata | null;
|
||||
current_version?: DocumentVersionMetadata | null;
|
||||
}
|
||||
|
||||
export interface DocumentMetadataItem {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
}
|
||||
|
||||
export const buildDocumentMetadataItems = (document?: DocumentLike | null): DocumentMetadataItem[] => {
|
||||
if (!document) {
|
||||
return [];
|
||||
}
|
||||
@@ -29,8 +52,8 @@ export const buildDocumentMetadataItems = (document) => {
|
||||
];
|
||||
};
|
||||
|
||||
export const extractDocumentMetadataPayload = (document) => {
|
||||
if (!document || !document.metadata) {
|
||||
export const extractDocumentMetadataPayload = (document?: DocumentLike | null): DocumentMetadata | null => {
|
||||
if (!document?.metadata) {
|
||||
return null;
|
||||
}
|
||||
const keys = Object.keys(document.metadata);
|
||||
+71
-24
@@ -1,13 +1,71 @@
|
||||
import { formatFileSize } from '../utils/format';
|
||||
import { formatDateTime as defaultFormatDateTime } from '../utils/date';
|
||||
|
||||
const coercePageCount = (metadata) => {
|
||||
interface DocumentMetadata {
|
||||
page_count?: number | string | null;
|
||||
}
|
||||
|
||||
interface DocumentVersion {
|
||||
size_bytes?: number | string | null;
|
||||
metadata?: DocumentMetadata | null;
|
||||
}
|
||||
|
||||
interface TagEntry {
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
interface CorrespondentEntry {
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export interface SummaryDocument {
|
||||
title?: string | null;
|
||||
original_name?: string | null;
|
||||
content_type?: string | null;
|
||||
current_version?: DocumentVersion | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
issued_at?: string | null;
|
||||
folder_path?: string | null;
|
||||
tags?: TagEntry[] | null;
|
||||
correspondents?: CorrespondentEntry[] | null;
|
||||
}
|
||||
|
||||
interface DescribeSummaryOptions {
|
||||
formatDateTime?: typeof defaultFormatDateTime;
|
||||
}
|
||||
|
||||
export interface DocumentSummaryRow {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface DocumentSummary {
|
||||
title: string | undefined;
|
||||
originalName: string | null | undefined;
|
||||
mimeTypeLabel: string;
|
||||
sizeLabel: string;
|
||||
createdAtLabel: string;
|
||||
issuedLabel: string;
|
||||
updatedAtLabel: string;
|
||||
pageCount: number | null;
|
||||
pageCountLabel: string;
|
||||
folderLabel: string | null | undefined;
|
||||
tags: TagEntry[];
|
||||
correspondents: CorrespondentEntry[];
|
||||
tagsSummary: string;
|
||||
correspondentsSummary: string;
|
||||
summaryRows: DocumentSummaryRow[];
|
||||
}
|
||||
|
||||
const coercePageCount = (metadata: DocumentMetadata | null | undefined): number | null => {
|
||||
const raw = metadata?.page_count;
|
||||
if (typeof raw === 'number') {
|
||||
return Number.isFinite(raw) && raw >= 0 ? raw : null;
|
||||
}
|
||||
if (raw != null && raw !== '') {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
const parsed = Number.parseInt(String(raw), 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
@@ -15,21 +73,14 @@ const coercePageCount = (metadata) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const sanitizeTags = (tags) => {
|
||||
if (!Array.isArray(tags)) {
|
||||
return [];
|
||||
}
|
||||
return tags.filter(Boolean);
|
||||
};
|
||||
const sanitizeArray = <T>(entries: (T | null | undefined)[] | null | undefined): T[] =>
|
||||
Array.isArray(entries) ? entries.filter(Boolean) as T[] : [];
|
||||
|
||||
const sanitizeCorrespondents = (entries) => {
|
||||
if (!Array.isArray(entries)) {
|
||||
return [];
|
||||
}
|
||||
return entries.filter(Boolean);
|
||||
};
|
||||
export const describeDocumentSummary = (document?: SummaryDocument | null, options: DescribeSummaryOptions = {}): DocumentSummary => {
|
||||
const {
|
||||
formatDateTime = defaultFormatDateTime,
|
||||
} = options;
|
||||
|
||||
export const describeDocumentSummary = (document, options = {}) => {
|
||||
if (!document) {
|
||||
return {
|
||||
title: '',
|
||||
@@ -50,10 +101,6 @@ export const describeDocumentSummary = (document, options = {}) => {
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
formatDateTime = defaultFormatDateTime,
|
||||
} = options;
|
||||
|
||||
const originalName = document.original_name;
|
||||
const mimeTypeLabel = document.content_type || 'Unknown';
|
||||
|
||||
@@ -70,18 +117,18 @@ export const describeDocumentSummary = (document, options = {}) => {
|
||||
|
||||
const folderLabel = document.folder_path;
|
||||
|
||||
const tags = sanitizeTags(document.tags);
|
||||
const correspondents = sanitizeCorrespondents(document.correspondents);
|
||||
const tags = sanitizeArray<TagEntry>(document.tags);
|
||||
const correspondents = sanitizeArray<CorrespondentEntry>(document.correspondents);
|
||||
|
||||
const tagLabels = tags.map((tag) => tag.label).filter(Boolean);
|
||||
const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean);
|
||||
const tagLabels = tags.map((tag) => tag.label).filter(Boolean) as string[];
|
||||
const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean) as string[];
|
||||
|
||||
const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—';
|
||||
const correspondentsSummary = correspondentLabels.length
|
||||
? correspondentLabels.join(', ')
|
||||
: '—';
|
||||
|
||||
const summaryRows = [
|
||||
const summaryRows: DocumentSummaryRow[] = [
|
||||
{ key: 'created', label: 'Created', value: createdAtLabel },
|
||||
{ key: 'size', label: 'Size', value: sizeLabel },
|
||||
{ key: 'type', label: 'Type', value: mimeTypeLabel },
|
||||
+38
-7
@@ -1,5 +1,36 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export type Identifier = string | number;
|
||||
|
||||
type ApiClient = {
|
||||
post: <T = { data: unknown }>(url: string, payload: unknown) => Promise<{ data: T } | T>;
|
||||
delete: (url: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type BulkAssignmentResponse = {
|
||||
assigned?: number;
|
||||
removed?: number;
|
||||
};
|
||||
|
||||
type CorrespondentAssignment = {
|
||||
correspondent_id?: Identifier;
|
||||
};
|
||||
|
||||
interface UseBulkDocumentActionsArgs {
|
||||
api: ApiClient;
|
||||
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
|
||||
correspondentLookupByName: Map<string, { id?: Identifier }>;
|
||||
handleCorrespondentCreate: (payload: { name: string }) => Promise<{ id?: Identifier } | null>;
|
||||
refreshCurrentFolder: () => Promise<void> | void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
selectedDocumentIds?: Identifier[];
|
||||
selectedFolderIds?: Identifier[];
|
||||
handleDocumentsDelete: (ids: Identifier[], options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise<boolean>;
|
||||
handleFolderDelete: (id: Identifier, options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise<boolean>;
|
||||
clearDocumentSelection: () => void;
|
||||
setLoading: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const useBulkDocumentActions = ({
|
||||
api,
|
||||
resolveTargetDocumentIds,
|
||||
@@ -13,9 +44,9 @@ const useBulkDocumentActions = ({
|
||||
handleFolderDelete,
|
||||
clearDocumentSelection,
|
||||
setLoading,
|
||||
}) => {
|
||||
}: UseBulkDocumentActionsArgs) => {
|
||||
const handleBulkCorrespondentAdd = useCallback(
|
||||
async ({ name, input, documentIds }) => {
|
||||
async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const trimmed = name?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Correspondent name is required.', 'error');
|
||||
@@ -41,7 +72,7 @@ const useBulkDocumentActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await api.post('/documents/bulk/correspondents', {
|
||||
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
|
||||
document_ids: targets,
|
||||
assignments: [
|
||||
{
|
||||
@@ -51,7 +82,7 @@ const useBulkDocumentActions = ({
|
||||
action: 'add',
|
||||
});
|
||||
|
||||
const { assigned = 0, removed = 0 } = response.data || {};
|
||||
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
|
||||
|
||||
await refreshCurrentFolder();
|
||||
const assignedSuffix = assigned === 1 ? '' : 's';
|
||||
@@ -83,7 +114,7 @@ const useBulkDocumentActions = ({
|
||||
);
|
||||
|
||||
const handleBulkCorrespondentRemove = useCallback(
|
||||
async ({ assignments = [], documentIds }) => {
|
||||
async ({ assignments = [], documentIds }: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => {
|
||||
if (!assignments.length) {
|
||||
setStatusMessage('Select a correspondent to remove.', 'error');
|
||||
return;
|
||||
@@ -100,13 +131,13 @@ const useBulkDocumentActions = ({
|
||||
correspondent_id: entry.correspondent_id,
|
||||
}));
|
||||
|
||||
const response = await api.post('/documents/bulk/correspondents', {
|
||||
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
|
||||
document_ids: targets,
|
||||
assignments: normalizedAssignments,
|
||||
action: 'remove',
|
||||
});
|
||||
|
||||
const { assigned = 0, removed = 0 } = response.data || {};
|
||||
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
|
||||
await refreshCurrentFolder();
|
||||
|
||||
if (removed > 0) {
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const useDocumentsPanelProps = ({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
refreshCurrentFolder,
|
||||
currentSubfolders,
|
||||
documents,
|
||||
searchResults,
|
||||
isFilterActive,
|
||||
folderClickHandlers,
|
||||
selectFolder,
|
||||
handleFolderDragStart,
|
||||
handleFolderDragEnd,
|
||||
draggedFolderId,
|
||||
handleFolderRename,
|
||||
openDocumentPreview,
|
||||
handleDocumentTitleUpdate,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
focusedRowKey,
|
||||
draggedDocumentIds,
|
||||
handleDocumentDragStart,
|
||||
handleDocumentDragEnd,
|
||||
searchLoading,
|
||||
tagLookupById,
|
||||
activeCorrespondentFilters,
|
||||
selectedEntries,
|
||||
setFocusedRowKey,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
toggleTagFilter,
|
||||
toggleCorrespondentFilter,
|
||||
handleDocumentTagDrop,
|
||||
documentsViewMode,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
handleDocumentsSortFieldChange,
|
||||
handleDocumentsSortDirectionToggle,
|
||||
searchIncludeDescendants,
|
||||
toggleSearchIncludeDescendants,
|
||||
handleDocumentsViewModeChange,
|
||||
clearDocumentSelection,
|
||||
handleDeleteSelection,
|
||||
handleEntryPointerCore,
|
||||
inspectDocument,
|
||||
handleEntrySelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkSelectionReanalyze,
|
||||
folderOptions,
|
||||
moveDocumentsToFolder,
|
||||
}) =>
|
||||
useMemo(
|
||||
() => ({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
onRefresh: refreshCurrentFolder,
|
||||
subfolders: currentSubfolders,
|
||||
documents,
|
||||
searchResults,
|
||||
isFilterActive,
|
||||
onFolderSelect: selectFolder,
|
||||
onFolderDrop: folderClickHandlers.onDrop,
|
||||
onFolderDragOver: folderClickHandlers.onDragOver,
|
||||
onFolderDragLeave: folderClickHandlers.onDragLeave,
|
||||
onFolderDragStart: handleFolderDragStart,
|
||||
onFolderDragEnd: handleFolderDragEnd,
|
||||
draggedFolderId,
|
||||
onFolderRename: handleFolderRename,
|
||||
onDocumentOpen: openDocumentPreview,
|
||||
onDocumentRename: handleDocumentTitleUpdate,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
focusedRowKey,
|
||||
draggingDocumentIds: draggedDocumentIds,
|
||||
onDocumentDragStart: handleDocumentDragStart,
|
||||
onDocumentDragEnd: handleDocumentDragEnd,
|
||||
isSearchLoading: searchLoading,
|
||||
tagLookupById,
|
||||
activeCorrespondentIds: activeCorrespondentFilters,
|
||||
selectedEntries,
|
||||
onFocusedRowChange: setFocusedRowKey,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
onTagClick: toggleTagFilter,
|
||||
onCorrespondentClick: toggleCorrespondentFilter,
|
||||
onDocumentTagDrop: handleDocumentTagDrop,
|
||||
viewMode: documentsViewMode,
|
||||
sortField: documentsSortField,
|
||||
sortDirection: documentsSortDirection,
|
||||
onSortFieldChange: handleDocumentsSortFieldChange,
|
||||
onSortDirectionToggle: handleDocumentsSortDirectionToggle,
|
||||
searchIncludeDescendants,
|
||||
onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
|
||||
onViewModeChange: handleDocumentsViewModeChange,
|
||||
onClearSelection: clearDocumentSelection,
|
||||
onDeleteSelection: handleDeleteSelection,
|
||||
onEntryPointer: handleEntryPointerCore,
|
||||
onInspectDocument: inspectDocument,
|
||||
onEntrySelection: handleEntrySelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
||||
onBulkReanalyze: handleBulkSelectionReanalyze,
|
||||
folderOptions,
|
||||
onMoveDocumentsToFolder: moveDocumentsToFolder,
|
||||
}),
|
||||
[
|
||||
activeCorrespondentFilters,
|
||||
breadcrumbs,
|
||||
clearDocumentSelection,
|
||||
correspondents,
|
||||
currentFolderName,
|
||||
currentSubfolders,
|
||||
documents,
|
||||
documentsSortDirection,
|
||||
documentsSortField,
|
||||
documentsViewMode,
|
||||
documentLookup,
|
||||
draggedDocumentIds,
|
||||
draggedFolderId,
|
||||
focusedRowKey,
|
||||
folderClickHandlers,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkSelectionReanalyze,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleDeleteSelection,
|
||||
handleDocumentDragEnd,
|
||||
handleDocumentDragStart,
|
||||
handleDocumentTagDrop,
|
||||
handleDocumentTitleUpdate,
|
||||
handleDocumentsSortDirectionToggle,
|
||||
handleDocumentsSortFieldChange,
|
||||
handleDocumentsViewModeChange,
|
||||
handleEntryPointerCore,
|
||||
handleEntrySelection,
|
||||
handleFolderDragEnd,
|
||||
handleFolderDragStart,
|
||||
handleFolderRename,
|
||||
inspectDocument,
|
||||
isFilterActive,
|
||||
moveDocumentsToFolder,
|
||||
openDocumentPreview,
|
||||
refreshCurrentFolder,
|
||||
searchIncludeDescendants,
|
||||
searchLoading,
|
||||
searchResults,
|
||||
selectedDocumentIds,
|
||||
selectedEntries,
|
||||
selectedFolderIds,
|
||||
selectFolder,
|
||||
setFocusedRowKey,
|
||||
tagLookupById,
|
||||
tags,
|
||||
toggleCorrespondentFilter,
|
||||
toggleSearchIncludeDescendants,
|
||||
toggleTagFilter,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
folderOptions,
|
||||
],
|
||||
);
|
||||
|
||||
export default useDocumentsPanelProps;
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
export interface Breadcrumb {
|
||||
id?: Identifier;
|
||||
name?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface FolderClickHandlers {
|
||||
onDrop?: (...args: unknown[]) => void;
|
||||
onDragOver?: (...args: unknown[]) => void;
|
||||
onDragLeave?: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export interface UseDocumentsPanelPropsArgs {
|
||||
currentFolderName?: string | null;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
refreshCurrentFolder?: () => void | Promise<void>;
|
||||
currentSubfolders?: unknown[];
|
||||
documents?: unknown[];
|
||||
searchResults?: unknown[] | null;
|
||||
isFilterActive?: boolean;
|
||||
folderClickHandlers: FolderClickHandlers;
|
||||
selectFolder?: (...args: unknown[]) => void;
|
||||
handleFolderDragStart?: (...args: unknown[]) => void;
|
||||
handleFolderDragEnd?: (...args: unknown[]) => void;
|
||||
draggedFolderId?: Identifier | null;
|
||||
handleFolderRename?: (...args: unknown[]) => void;
|
||||
openDocumentPreview?: (...args: unknown[]) => void;
|
||||
handleDocumentTitleUpdate?: (...args: unknown[]) => void;
|
||||
selectedDocumentIds?: Identifier[];
|
||||
selectedFolderIds?: Identifier[];
|
||||
focusedRowKey?: Identifier | string | null;
|
||||
draggedDocumentIds?: Identifier[];
|
||||
handleDocumentDragStart?: (...args: unknown[]) => void;
|
||||
handleDocumentDragEnd?: (...args: unknown[]) => void;
|
||||
searchLoading?: boolean;
|
||||
tagLookupById?: unknown;
|
||||
activeCorrespondentFilters?: Identifier[];
|
||||
selectedEntries?: unknown[];
|
||||
setFocusedRowKey?: (key: Identifier | string | null) => void;
|
||||
ensureAssetUrl?: (...args: unknown[]) => void;
|
||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||
toggleTagFilter?: (...args: unknown[]) => void;
|
||||
toggleCorrespondentFilter?: (...args: unknown[]) => void;
|
||||
handleDocumentTagDrop?: (...args: unknown[]) => void;
|
||||
documentsViewMode?: string;
|
||||
documentsSortField?: string;
|
||||
documentsSortDirection?: string;
|
||||
handleDocumentsSortFieldChange?: (field: string) => void;
|
||||
handleDocumentsSortDirectionToggle?: () => void;
|
||||
searchIncludeDescendants?: boolean;
|
||||
toggleSearchIncludeDescendants?: () => void;
|
||||
handleDocumentsViewModeChange?: (mode: string) => void;
|
||||
clearDocumentSelection?: () => void;
|
||||
handleDeleteSelection?: () => void;
|
||||
handleEntryPointerCore?: (...args: unknown[]) => void;
|
||||
inspectDocument?: (...args: unknown[]) => void;
|
||||
handleEntrySelection?: (...args: unknown[]) => void;
|
||||
tags?: unknown[];
|
||||
correspondents?: unknown[];
|
||||
documentLookup?: unknown;
|
||||
handleBulkTagAddFromDetail?: (...args: unknown[]) => void;
|
||||
handleBulkTagRemoveFromDetail?: (...args: unknown[]) => void;
|
||||
handleBulkCorrespondentAdd?: (...args: unknown[]) => void;
|
||||
handleBulkCorrespondentRemove?: (...args: unknown[]) => void;
|
||||
handleBulkSelectionReanalyze?: (...args: unknown[]) => void;
|
||||
folderOptions?: unknown[];
|
||||
moveDocumentsToFolder?: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export type DocumentsPanelProps = ReturnType<typeof useDocumentsPanelProps>;
|
||||
|
||||
const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
||||
const {
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
refreshCurrentFolder,
|
||||
currentSubfolders,
|
||||
documents,
|
||||
searchResults,
|
||||
isFilterActive,
|
||||
folderClickHandlers,
|
||||
selectFolder,
|
||||
handleFolderDragStart,
|
||||
handleFolderDragEnd,
|
||||
draggedFolderId,
|
||||
handleFolderRename,
|
||||
openDocumentPreview,
|
||||
handleDocumentTitleUpdate,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
focusedRowKey,
|
||||
draggedDocumentIds,
|
||||
handleDocumentDragStart,
|
||||
handleDocumentDragEnd,
|
||||
searchLoading,
|
||||
tagLookupById,
|
||||
activeCorrespondentFilters,
|
||||
selectedEntries,
|
||||
setFocusedRowKey,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
toggleTagFilter,
|
||||
toggleCorrespondentFilter,
|
||||
handleDocumentTagDrop,
|
||||
documentsViewMode,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
handleDocumentsSortFieldChange,
|
||||
handleDocumentsSortDirectionToggle,
|
||||
searchIncludeDescendants,
|
||||
toggleSearchIncludeDescendants,
|
||||
handleDocumentsViewModeChange,
|
||||
clearDocumentSelection,
|
||||
handleDeleteSelection,
|
||||
handleEntryPointerCore,
|
||||
inspectDocument,
|
||||
handleEntrySelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkSelectionReanalyze,
|
||||
folderOptions,
|
||||
moveDocumentsToFolder,
|
||||
} = props;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
onRefresh: refreshCurrentFolder,
|
||||
subfolders: currentSubfolders,
|
||||
documents,
|
||||
searchResults,
|
||||
isFilterActive,
|
||||
onFolderSelect: selectFolder,
|
||||
onFolderDrop: folderClickHandlers.onDrop,
|
||||
onFolderDragOver: folderClickHandlers.onDragOver,
|
||||
onFolderDragLeave: folderClickHandlers.onDragLeave,
|
||||
onFolderDragStart: handleFolderDragStart,
|
||||
onFolderDragEnd: handleFolderDragEnd,
|
||||
draggedFolderId,
|
||||
onFolderRename: handleFolderRename,
|
||||
onDocumentOpen: openDocumentPreview,
|
||||
onDocumentRename: handleDocumentTitleUpdate,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
focusedRowKey,
|
||||
draggingDocumentIds: draggedDocumentIds,
|
||||
onDocumentDragStart: handleDocumentDragStart,
|
||||
onDocumentDragEnd: handleDocumentDragEnd,
|
||||
isSearchLoading: searchLoading,
|
||||
tagLookupById,
|
||||
activeCorrespondentIds: activeCorrespondentFilters,
|
||||
selectedEntries,
|
||||
onFocusedRowChange: setFocusedRowKey,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
onTagClick: toggleTagFilter,
|
||||
onCorrespondentClick: toggleCorrespondentFilter,
|
||||
onDocumentTagDrop: handleDocumentTagDrop,
|
||||
viewMode: documentsViewMode,
|
||||
sortField: documentsSortField,
|
||||
sortDirection: documentsSortDirection,
|
||||
onSortFieldChange: handleDocumentsSortFieldChange,
|
||||
onSortDirectionToggle: handleDocumentsSortDirectionToggle,
|
||||
searchIncludeDescendants,
|
||||
onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
|
||||
onViewModeChange: handleDocumentsViewModeChange,
|
||||
onClearSelection: clearDocumentSelection,
|
||||
onDeleteSelection: handleDeleteSelection,
|
||||
onEntryPointer: handleEntryPointerCore,
|
||||
onInspectDocument: inspectDocument,
|
||||
onEntrySelection: handleEntrySelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
||||
onBulkReanalyze: handleBulkSelectionReanalyze,
|
||||
folderOptions,
|
||||
onMoveDocumentsToFolder: moveDocumentsToFolder,
|
||||
}),
|
||||
[
|
||||
activeCorrespondentFilters,
|
||||
breadcrumbs,
|
||||
clearDocumentSelection,
|
||||
correspondents,
|
||||
currentFolderName,
|
||||
currentSubfolders,
|
||||
documents,
|
||||
documentsSortDirection,
|
||||
documentsSortField,
|
||||
documentsViewMode,
|
||||
documentLookup,
|
||||
draggedDocumentIds,
|
||||
draggedFolderId,
|
||||
focusedRowKey,
|
||||
folderClickHandlers,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkSelectionReanalyze,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleDeleteSelection,
|
||||
handleDocumentDragEnd,
|
||||
handleDocumentDragStart,
|
||||
handleDocumentTagDrop,
|
||||
handleDocumentTitleUpdate,
|
||||
handleDocumentsSortDirectionToggle,
|
||||
handleDocumentsSortFieldChange,
|
||||
handleDocumentsViewModeChange,
|
||||
handleEntryPointerCore,
|
||||
handleEntrySelection,
|
||||
handleFolderDragEnd,
|
||||
handleFolderDragStart,
|
||||
handleFolderRename,
|
||||
inspectDocument,
|
||||
isFilterActive,
|
||||
moveDocumentsToFolder,
|
||||
openDocumentPreview,
|
||||
refreshCurrentFolder,
|
||||
searchIncludeDescendants,
|
||||
searchLoading,
|
||||
searchResults,
|
||||
selectedDocumentIds,
|
||||
selectedEntries,
|
||||
selectedFolderIds,
|
||||
selectFolder,
|
||||
setFocusedRowKey,
|
||||
tagLookupById,
|
||||
tags,
|
||||
toggleCorrespondentFilter,
|
||||
toggleSearchIncludeDescendants,
|
||||
toggleTagFilter,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
folderOptions,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
export default useDocumentsPanelProps;
|
||||
+41
-5
@@ -1,5 +1,41 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
interface FolderEntry {
|
||||
id: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DocumentEntry {
|
||||
id: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface NavigableRow {
|
||||
key: string;
|
||||
type: 'folder' | 'document';
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
interface UseDocumentsSelectionOptions {
|
||||
showingSearchResults: boolean;
|
||||
currentSubfolders: FolderEntry[];
|
||||
visibleDocuments: DocumentEntry[];
|
||||
resolveFolderRowKey: (id: string | number) => string | null | undefined;
|
||||
resolveDocumentRowKey: (id: string | number) => string | null | undefined;
|
||||
configureSelectionEnvironment: (config: { visibleRowKeySet: Set<string>; navigableRowKeys: string[] }) => void;
|
||||
visibleRowKeySet: Set<string>;
|
||||
selectedEntries: string[];
|
||||
selectionAnchorRef: { current: string | null };
|
||||
promoteSelectionOrderRaw: (id: string | number) => void;
|
||||
setFocusedDocumentId: (id: string | number | null) => void;
|
||||
setActivePreviewId: (id: string | number | null) => void;
|
||||
clearSelection: () => void;
|
||||
focusedDocumentId: string | number | null;
|
||||
setFocusedRowKey: (value: string | null | ((current: string | null) => string | null)) => void;
|
||||
focusedRowKey: string | null;
|
||||
isFolderRowKey: (key: string | null) => boolean;
|
||||
}
|
||||
|
||||
const useDocumentsSelection = ({
|
||||
showingSearchResults,
|
||||
currentSubfolders,
|
||||
@@ -18,9 +54,9 @@ const useDocumentsSelection = ({
|
||||
setFocusedRowKey,
|
||||
focusedRowKey,
|
||||
isFolderRowKey,
|
||||
}) => {
|
||||
const navigableRows = useMemo(() => {
|
||||
const entries = [];
|
||||
}: UseDocumentsSelectionOptions) => {
|
||||
const navigableRows = useMemo<NavigableRow[]>(() => {
|
||||
const entries: NavigableRow[] = [];
|
||||
if (!showingSearchResults) {
|
||||
currentSubfolders.forEach((folder) => {
|
||||
const key = resolveFolderRowKey(folder.id);
|
||||
@@ -51,7 +87,7 @@ const useDocumentsSelection = ({
|
||||
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
|
||||
|
||||
const promoteSelectionOrder = useCallback(
|
||||
(docId) => {
|
||||
(docId: string | number | null) => {
|
||||
if (!docId) return;
|
||||
promoteSelectionOrderRaw(docId);
|
||||
const rowKey = resolveDocumentRowKey(docId);
|
||||
@@ -68,7 +104,7 @@ const useDocumentsSelection = ({
|
||||
clearSelection();
|
||||
}, [clearSelection]);
|
||||
|
||||
const prevFocusedDocIdRef = useRef(focusedDocumentId);
|
||||
const prevFocusedDocIdRef = useRef<string | number | null>(focusedDocumentId);
|
||||
useEffect(() => {
|
||||
const previous = prevFocusedDocIdRef.current;
|
||||
if (previous === focusedDocumentId) {
|
||||
+5
-1
@@ -15,7 +15,11 @@ const EntryType = {
|
||||
document: 'document',
|
||||
};
|
||||
|
||||
const DocumentsPanel = ({
|
||||
interface DocumentsPanelProps {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
onRefresh,
|
||||
+22
-6
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import {
|
||||
ViewListIcon,
|
||||
ViewGridIcon,
|
||||
@@ -12,18 +12,34 @@ import {
|
||||
} from '../../ui/icons';
|
||||
import SortFieldQuickMenu from './SortFieldQuickMenu';
|
||||
|
||||
type ViewMode = 'list' | 'grid' | 'desk' | (string & {});
|
||||
type SortDirection = 'asc' | 'desc' | (string & {});
|
||||
|
||||
interface DocumentsTableHeaderActionOptions {
|
||||
viewMode?: ViewMode;
|
||||
onViewModeChange?: (mode: ViewMode) => void;
|
||||
onRefresh: () => void;
|
||||
sortField?: string;
|
||||
onSortFieldChange?: (field: string) => void;
|
||||
sortDirection?: SortDirection;
|
||||
onSortDirectionToggle?: () => void;
|
||||
isFilterActive?: boolean;
|
||||
includeDescendants?: boolean;
|
||||
onToggleIncludeDescendants?: () => void;
|
||||
}
|
||||
|
||||
export const createDocumentsTableHeaderActions = ({
|
||||
viewMode,
|
||||
viewMode = 'list',
|
||||
onViewModeChange,
|
||||
onRefresh,
|
||||
sortField = 'title',
|
||||
onSortFieldChange = null,
|
||||
onSortFieldChange,
|
||||
sortDirection = 'asc',
|
||||
onSortDirectionToggle = null,
|
||||
onSortDirectionToggle,
|
||||
isFilterActive = false,
|
||||
includeDescendants = true,
|
||||
onToggleIncludeDescendants = null,
|
||||
}) => {
|
||||
onToggleIncludeDescendants,
|
||||
}: DocumentsTableHeaderActionOptions): JSX.Element => {
|
||||
const isListView = viewMode === 'list';
|
||||
const isGridView = viewMode === 'grid';
|
||||
const isDeskView = viewMode === 'desk';
|
||||
+8
-3
@@ -14,7 +14,12 @@ const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((acc, option) => {
|
||||
return next;
|
||||
}, {});
|
||||
|
||||
const SortFieldQuickMenu = ({ sortField, onChange }) => {
|
||||
interface SortFieldQuickMenuProps {
|
||||
sortField: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const SortFieldQuickMenu: React.FC<SortFieldQuickMenuProps> = ({ sortField, onChange }) => {
|
||||
const currentOption = useMemo(
|
||||
() => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0],
|
||||
[sortField],
|
||||
@@ -26,8 +31,8 @@ const SortFieldQuickMenu = ({ sortField, onChange }) => {
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value, option) => {
|
||||
if (typeof onChange !== 'function') {
|
||||
(value: string, option?: { id?: string; original?: { id?: string } }) => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
const nextValue = option?.id || option?.original?.id || value;
|
||||
+18
-2
@@ -1,10 +1,26 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import DocumentViewerPanel from '../../preview/DocumentViewerPanel';
|
||||
import SelectionFloatingActions from '../SelectionFloatingActions';
|
||||
import createWorkspaceSurfaceConfig from '../workspaceHeader';
|
||||
import DocumentsPanel from './DocumentsPanel';
|
||||
import { createDocumentsTableHeaderActions } from './DocumentsToolbar';
|
||||
|
||||
interface Breadcrumb {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface CreateDocumentsSurfaceArgs {
|
||||
tableProps: Record<string, any>;
|
||||
parentBreadcrumb?: Breadcrumb | null;
|
||||
onNavigateParent?: () => void;
|
||||
renderSidebarToggle?: () => ReactNode;
|
||||
detailProps?: Record<string, any> | null;
|
||||
detailOpen?: boolean;
|
||||
}
|
||||
|
||||
const createDocumentsSurface = ({
|
||||
tableProps,
|
||||
parentBreadcrumb,
|
||||
@@ -12,7 +28,7 @@ const createDocumentsSurface = ({
|
||||
renderSidebarToggle,
|
||||
detailProps,
|
||||
detailOpen = false,
|
||||
}) => {
|
||||
}: CreateDocumentsSurfaceArgs) => {
|
||||
const {
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
@@ -1,7 +1,18 @@
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
const TAG_TEXT_MIME_TYPE = 'text/plain';
|
||||
|
||||
const serializePayload = (payload) => {
|
||||
interface TagPayload {
|
||||
id: string | number;
|
||||
label: string;
|
||||
sourceDocId: string | number | null;
|
||||
}
|
||||
|
||||
interface TagLike {
|
||||
id?: string | number;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
const serializePayload = (payload: TagPayload): string | null => {
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch (error) {
|
||||
@@ -10,8 +21,8 @@ const serializePayload = (payload) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const createTagTransferPayload = (tag, sourceDocId = null) => {
|
||||
if (!tag || !tag.id) {
|
||||
export const createTagTransferPayload = (tag: TagLike | null | undefined, sourceDocId: string | number | null = null): TagPayload | null => {
|
||||
if (!tag || tag.id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -22,7 +33,7 @@ export const createTagTransferPayload = (tag, sourceDocId = null) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => {
|
||||
export const writeTagTransferData = (dataTransfer: DataTransfer | null, tag: TagLike, sourceDocId: string | number | null = null): void => {
|
||||
if (!dataTransfer) {
|
||||
return;
|
||||
}
|
||||
@@ -38,8 +49,7 @@ export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => {
|
||||
}
|
||||
|
||||
try {
|
||||
dataTransfer.setData(TAG_MIME_TYPES[0], serialized);
|
||||
dataTransfer.setData(TAG_MIME_TYPES[1], serialized);
|
||||
TAG_MIME_TYPES.forEach((type) => dataTransfer.setData(type, serialized));
|
||||
if (payload.label) {
|
||||
dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label);
|
||||
}
|
||||
@@ -48,7 +58,7 @@ export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const readTagTransferData = (dataTransfer) => {
|
||||
export const readTagTransferData = (dataTransfer: DataTransfer | null | undefined): string | null => {
|
||||
if (!dataTransfer) {
|
||||
return null;
|
||||
}
|
||||
@@ -67,15 +77,17 @@ export const readTagTransferData = (dataTransfer) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const parseTagTransferPayload = (input) => {
|
||||
type DragEventLike = DragEvent | { dataTransfer?: DataTransfer | null };
|
||||
|
||||
export const parseTagTransferPayload = (input: DataTransfer | DragEventLike | null): TagPayload | null => {
|
||||
const dataTransfer = input && 'dataTransfer' in input ? input.dataTransfer : input;
|
||||
const raw = readTagTransferData(dataTransfer);
|
||||
const raw = readTagTransferData(dataTransfer || null);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
return JSON.parse(raw) as TagPayload;
|
||||
} catch (error) {
|
||||
console.warn('[tagTransfer] Failed to parse drag payload', error);
|
||||
}
|
||||
@@ -83,7 +95,7 @@ export const parseTagTransferPayload = (input) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isTagTransferEvent = (event) => {
|
||||
export const isTagTransferEvent = (event?: DragEventLike | null): boolean => {
|
||||
const types = event?.dataTransfer?.types;
|
||||
if (!types) {
|
||||
return false;
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const isPointerModifierEvent = (event) =>
|
||||
Boolean(event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey));
|
||||
|
||||
export const isPrimaryPointerEvent = (event) => {
|
||||
if (!event) {
|
||||
return true;
|
||||
}
|
||||
if (typeof event.button === 'number' && event.button !== 0) {
|
||||
return false;
|
||||
}
|
||||
const type = event?.type?.toLowerCase?.() ?? '';
|
||||
return type === 'click' || type === 'pointerdown' || type === 'pointerup';
|
||||
};
|
||||
|
||||
const EntryType = Object.freeze({
|
||||
document: 'document',
|
||||
folder: 'folder',
|
||||
});
|
||||
|
||||
export const useEntryPointer = ({
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
onSelectEntry,
|
||||
onInspectDocument,
|
||||
}) =>
|
||||
useCallback(
|
||||
(entry, event) => {
|
||||
if (!entry || !entry.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, id } = entry;
|
||||
if (type !== EntryType.document && type !== EntryType.folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowKey = entry.key
|
||||
|| (type === EntryType.document ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
|
||||
if (!rowKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modifierClick = isPointerModifierEvent(event);
|
||||
const primaryClick = isPrimaryPointerEvent(event);
|
||||
const metadata = { modifierClick, primaryClick, rowKey, type, id };
|
||||
|
||||
if (typeof onSelectEntry === 'function') {
|
||||
onSelectEntry(entry, event, metadata);
|
||||
}
|
||||
|
||||
if (
|
||||
type === EntryType.document
|
||||
&& !modifierClick
|
||||
&& primaryClick
|
||||
&& typeof onInspectDocument === 'function'
|
||||
) {
|
||||
onInspectDocument(id, metadata);
|
||||
}
|
||||
},
|
||||
[resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, onInspectDocument],
|
||||
);
|
||||
|
||||
export default useEntryPointer;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export type PointerEventLike = MouseEvent | PointerEvent;
|
||||
|
||||
export const isPointerModifierEvent = (event?: PointerEventLike | null): boolean =>
|
||||
Boolean(event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey));
|
||||
|
||||
export const isPrimaryPointerEvent = (event?: PointerEventLike | null): boolean => {
|
||||
if (!event) {
|
||||
return true;
|
||||
}
|
||||
if (typeof event.button === 'number' && event.button !== 0) {
|
||||
return false;
|
||||
}
|
||||
const type = event?.type?.toLowerCase?.() ?? '';
|
||||
return type === 'click' || type === 'pointerdown' || type === 'pointerup';
|
||||
};
|
||||
|
||||
export type EntryType = 'document' | 'folder';
|
||||
|
||||
export interface WorkspaceEntry {
|
||||
id: string | number;
|
||||
key?: string;
|
||||
type: EntryType;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseEntryPointerOptions {
|
||||
resolveDocumentRowKey?: (id: string | number) => string | null | undefined;
|
||||
resolveFolderRowKey?: (id: string | number) => string | null | undefined;
|
||||
onSelectEntry?: (entry: WorkspaceEntry, event?: PointerEventLike | null, metadata?: EntryPointerMetadata) => void;
|
||||
onInspectDocument?: (id: string | number, metadata?: EntryPointerMetadata) => void;
|
||||
}
|
||||
|
||||
export interface EntryPointerMetadata {
|
||||
modifierClick: boolean;
|
||||
primaryClick: boolean;
|
||||
rowKey: string;
|
||||
type: EntryType;
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
export const useEntryPointer = ({
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
onSelectEntry,
|
||||
onInspectDocument,
|
||||
}: UseEntryPointerOptions) =>
|
||||
useCallback(
|
||||
(entry?: WorkspaceEntry | null, event?: PointerEventLike | null) => {
|
||||
if (!entry || !entry.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, id } = entry;
|
||||
if (type !== 'document' && type !== 'folder') {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowKey = entry.key
|
||||
|| (type === 'document' ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
|
||||
if (!rowKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modifierClick = isPointerModifierEvent(event);
|
||||
const primaryClick = isPrimaryPointerEvent(event);
|
||||
const metadata: EntryPointerMetadata = { modifierClick, primaryClick, rowKey, type, id };
|
||||
|
||||
onSelectEntry?.(entry, event, metadata);
|
||||
|
||||
if (type === 'document' && !modifierClick && primaryClick) {
|
||||
onInspectDocument?.(id, metadata);
|
||||
}
|
||||
},
|
||||
[resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, onInspectDocument],
|
||||
);
|
||||
|
||||
export default useEntryPointer;
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
const focusInput = (node) => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const applyFocus = () => {
|
||||
node.focus();
|
||||
node.select?.();
|
||||
};
|
||||
const raf = window.requestAnimationFrame;
|
||||
if (raf) {
|
||||
raf(applyFocus);
|
||||
return;
|
||||
}
|
||||
applyFocus();
|
||||
};
|
||||
|
||||
const identity = (value) => value;
|
||||
|
||||
const useInlineRename = (
|
||||
onRename,
|
||||
{
|
||||
getCurrentValue = identity,
|
||||
getEntityId = (entity) => entity?.id ?? null,
|
||||
} = {},
|
||||
) => {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [draftValue, setDraftValue] = useState('');
|
||||
const [savingId, setSavingId] = useState(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setDraftValue('');
|
||||
setSavingId(null);
|
||||
inputRef.current = null;
|
||||
}, []);
|
||||
|
||||
const beginEditing = useCallback(
|
||||
(entity, event) => {
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
const entityId = getEntityId(entity);
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
const currentValue = getCurrentValue(entity) ?? '';
|
||||
setEditingId(entityId);
|
||||
setDraftValue(currentValue);
|
||||
setSavingId(null);
|
||||
},
|
||||
[getCurrentValue, getEntityId],
|
||||
);
|
||||
|
||||
const cancelEditing = useCallback(
|
||||
(event) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
resetState();
|
||||
},
|
||||
[resetState],
|
||||
);
|
||||
|
||||
const submitEditing = useCallback(
|
||||
async (entity) => {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
const entityId = getEntityId(entity);
|
||||
if (!entityId || editingId !== entityId) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = draftValue.trim();
|
||||
const currentValue = getCurrentValue(entity) ?? '';
|
||||
if (!trimmed || trimmed === currentValue) {
|
||||
resetState();
|
||||
return true;
|
||||
}
|
||||
if (typeof onRename !== 'function') {
|
||||
resetState();
|
||||
return true;
|
||||
}
|
||||
setSavingId(entityId);
|
||||
try {
|
||||
const result = await onRename(entityId, trimmed);
|
||||
if (result === false) {
|
||||
return false;
|
||||
}
|
||||
resetState();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setSavingId((current) => (current === entityId ? null : current));
|
||||
}
|
||||
},
|
||||
[draftValue, editingId, getCurrentValue, getEntityId, onRename, resetState],
|
||||
);
|
||||
|
||||
const attachInputRef = useCallback(
|
||||
(node) => {
|
||||
if (node) {
|
||||
inputRef.current = node;
|
||||
focusInput(node);
|
||||
} else if (inputRef.current) {
|
||||
inputRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
editingId,
|
||||
draftValue,
|
||||
setDraftValue,
|
||||
beginEditing,
|
||||
cancelEditing,
|
||||
submitEditing,
|
||||
savingId,
|
||||
attachInputRef,
|
||||
};
|
||||
};
|
||||
|
||||
export default useInlineRename;
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
type FocusableInput = (HTMLInputElement | HTMLTextAreaElement) & {
|
||||
select?: () => void;
|
||||
};
|
||||
|
||||
type InlineRenameOptions<TEntity> = {
|
||||
getCurrentValue?: (entity: TEntity) => string | null | undefined;
|
||||
getEntityId?: (entity: TEntity) => string | number | null | undefined;
|
||||
};
|
||||
|
||||
type InlineRenameHandler = (
|
||||
id: string | number,
|
||||
value: string,
|
||||
) => boolean | void | Promise<boolean | void>;
|
||||
|
||||
type InlineRenameReturn<TEntity> = {
|
||||
editingId: string | number | null;
|
||||
draftValue: string;
|
||||
setDraftValue: Dispatch<SetStateAction<string>>;
|
||||
beginEditing: (entity: TEntity | null | undefined, event?: SyntheticEvent | Event) => void;
|
||||
cancelEditing: (event?: SyntheticEvent | Event) => void;
|
||||
submitEditing: (entity: TEntity | null | undefined) => Promise<boolean>;
|
||||
savingId: string | number | null;
|
||||
attachInputRef: (node: FocusableInput | null) => void;
|
||||
};
|
||||
|
||||
const focusInput = (node: FocusableInput | null) => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const applyFocus = () => {
|
||||
node.focus();
|
||||
node.select?.();
|
||||
};
|
||||
const raf = window.requestAnimationFrame;
|
||||
if (raf) {
|
||||
raf(applyFocus);
|
||||
return;
|
||||
}
|
||||
applyFocus();
|
||||
};
|
||||
|
||||
const identity = (value: unknown) => value as string;
|
||||
|
||||
const defaultGetEntityId = <T,>(entity: T) =>
|
||||
(entity as { id?: string | number } | null | undefined)?.id ?? null;
|
||||
|
||||
const useInlineRename = <TEntity,>(
|
||||
onRename?: InlineRenameHandler,
|
||||
{
|
||||
getCurrentValue = identity as (entity: TEntity) => string | null | undefined,
|
||||
getEntityId = defaultGetEntityId as (
|
||||
entity: TEntity,
|
||||
) => string | number | null | undefined,
|
||||
}: InlineRenameOptions<TEntity> = {},
|
||||
): InlineRenameReturn<TEntity> => {
|
||||
const [editingId, setEditingId] = useState<string | number | null>(null);
|
||||
const [draftValue, setDraftValue] = useState('');
|
||||
const [savingId, setSavingId] = useState<string | number | null>(null);
|
||||
const inputRef = useRef<FocusableInput | null>(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setDraftValue('');
|
||||
setSavingId(null);
|
||||
inputRef.current = null;
|
||||
}, []);
|
||||
|
||||
const beginEditing = useCallback(
|
||||
(entity: TEntity | null | undefined, event?: SyntheticEvent | Event) => {
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
const entityId = getEntityId(entity);
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
const currentValue = getCurrentValue(entity) ?? '';
|
||||
setEditingId(entityId);
|
||||
setDraftValue(currentValue);
|
||||
setSavingId(null);
|
||||
},
|
||||
[getCurrentValue, getEntityId],
|
||||
);
|
||||
|
||||
const cancelEditing = useCallback(
|
||||
(event?: SyntheticEvent | Event) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
resetState();
|
||||
},
|
||||
[resetState],
|
||||
);
|
||||
|
||||
const submitEditing = useCallback(
|
||||
async (entity: TEntity | null | undefined) => {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
const entityId = getEntityId(entity);
|
||||
if (!entityId || editingId !== entityId) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = draftValue.trim();
|
||||
const currentValue = getCurrentValue(entity) ?? '';
|
||||
if (!trimmed || trimmed === currentValue) {
|
||||
resetState();
|
||||
return true;
|
||||
}
|
||||
if (!onRename) {
|
||||
resetState();
|
||||
return true;
|
||||
}
|
||||
setSavingId(entityId);
|
||||
try {
|
||||
const result = await onRename(entityId, trimmed);
|
||||
if (result === false) {
|
||||
return false;
|
||||
}
|
||||
resetState();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setSavingId((current) => (current === entityId ? null : current));
|
||||
}
|
||||
},
|
||||
[draftValue, editingId, getCurrentValue, getEntityId, onRename, resetState],
|
||||
);
|
||||
|
||||
const attachInputRef = useCallback((node: FocusableInput | null) => {
|
||||
if (node) {
|
||||
inputRef.current = node;
|
||||
focusInput(node);
|
||||
} else if (inputRef.current) {
|
||||
inputRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
editingId,
|
||||
draftValue,
|
||||
setDraftValue,
|
||||
beginEditing,
|
||||
cancelEditing,
|
||||
submitEditing,
|
||||
savingId,
|
||||
attachInputRef,
|
||||
};
|
||||
};
|
||||
|
||||
export default useInlineRename;
|
||||
@@ -1,41 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export const createWorkspaceSurfaceConfig = ({
|
||||
title,
|
||||
subtitle = null,
|
||||
sidebarToggle = null,
|
||||
actions = null,
|
||||
breadcrumbs = null,
|
||||
selectionLabel = null,
|
||||
floatingActions = null,
|
||||
content = null,
|
||||
detail = null,
|
||||
variant = 'documents',
|
||||
key = 'documents',
|
||||
}) => {
|
||||
const leading = sidebarToggle
|
||||
? (
|
||||
<>
|
||||
{sidebarToggle}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
key,
|
||||
variant,
|
||||
header: {
|
||||
title,
|
||||
subtitle,
|
||||
leading,
|
||||
actions,
|
||||
breadcrumbs,
|
||||
selectionLabel,
|
||||
floatingActions,
|
||||
},
|
||||
content,
|
||||
detail,
|
||||
};
|
||||
};
|
||||
|
||||
export default createWorkspaceSurfaceConfig;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface WorkspaceSurfaceHeaderConfig {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
breadcrumbs?: ReactNode;
|
||||
selectionLabel?: ReactNode;
|
||||
floatingActions?: ReactNode;
|
||||
}
|
||||
|
||||
interface WorkspaceSurfaceConfig {
|
||||
key: string;
|
||||
variant: string;
|
||||
header: WorkspaceSurfaceHeaderConfig;
|
||||
content?: ReactNode;
|
||||
detail?: ReactNode;
|
||||
}
|
||||
|
||||
interface CreateWorkspaceSurfaceConfigArgs {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
sidebarToggle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
breadcrumbs?: ReactNode;
|
||||
selectionLabel?: ReactNode;
|
||||
floatingActions?: ReactNode;
|
||||
content?: ReactNode;
|
||||
detail?: ReactNode;
|
||||
variant?: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export const createWorkspaceSurfaceConfig = ({
|
||||
title,
|
||||
subtitle = null,
|
||||
sidebarToggle = null,
|
||||
actions = null,
|
||||
breadcrumbs = null,
|
||||
selectionLabel = null,
|
||||
floatingActions = null,
|
||||
content = null,
|
||||
detail = null,
|
||||
variant = 'documents',
|
||||
key = 'documents',
|
||||
}: CreateWorkspaceSurfaceConfigArgs): WorkspaceSurfaceConfig => {
|
||||
const leading = sidebarToggle ? <>{sidebarToggle}</> : null;
|
||||
|
||||
return {
|
||||
key,
|
||||
variant,
|
||||
header: {
|
||||
title,
|
||||
subtitle,
|
||||
leading,
|
||||
actions,
|
||||
breadcrumbs,
|
||||
selectionLabel,
|
||||
floatingActions,
|
||||
},
|
||||
content,
|
||||
detail,
|
||||
};
|
||||
};
|
||||
|
||||
export default createWorkspaceSurfaceConfig;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export const useDocumentsStore = () => {
|
||||
const [status, setStatus] = useState(null);
|
||||
|
||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||
setStatus(message ? { message, variant } : null);
|
||||
}, []);
|
||||
|
||||
return { status, setStatusMessage };
|
||||
};
|
||||
|
||||
export default useDocumentsStore;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export type StatusVariant = 'info' | 'success' | 'error';
|
||||
|
||||
export interface StatusMessage {
|
||||
message: string;
|
||||
variant: StatusVariant;
|
||||
}
|
||||
|
||||
export const useDocumentsStore = () => {
|
||||
const [status, setStatus] = useState<StatusMessage | null>(null);
|
||||
|
||||
const setStatusMessage = useCallback((message?: string | null, variant: StatusVariant = 'info') => {
|
||||
setStatus(message ? { message, variant } : null);
|
||||
}, []);
|
||||
|
||||
return { status, setStatusMessage };
|
||||
};
|
||||
|
||||
export default useDocumentsStore;
|
||||
+62
-13
@@ -1,4 +1,52 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { AxiosHeaders } from 'axios';
|
||||
|
||||
type AppStatus = string;
|
||||
|
||||
type AppDispatch = (action: { type: string; [key: string]: unknown }) => void;
|
||||
|
||||
type NotifyApiError = (error: unknown, fallbackMessage: string, variant?: string) => void;
|
||||
|
||||
type SetStatusMessage = (message: string, variant?: string) => void;
|
||||
|
||||
type SetLoading = (state: boolean) => void;
|
||||
|
||||
interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig {
|
||||
_retry?: boolean;
|
||||
}
|
||||
|
||||
interface UseAuthManagerArgs {
|
||||
apiClient: AxiosInstance;
|
||||
token?: string | null;
|
||||
appStatus: AppStatus;
|
||||
appDispatch: AppDispatch;
|
||||
notifyApiError: NotifyApiError;
|
||||
setStatusMessage: SetStatusMessage;
|
||||
setLoading: SetLoading;
|
||||
}
|
||||
|
||||
interface UseAuthManagerResult {
|
||||
tokenRef: MutableRefObject<string | null | undefined>;
|
||||
refreshAccessToken: () => Promise<string>;
|
||||
handleLogout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ensureAxiosHeaders = (
|
||||
headers?: InternalAxiosRequestConfig['headers'],
|
||||
): AxiosHeaders => {
|
||||
if (headers instanceof AxiosHeaders) {
|
||||
return headers;
|
||||
}
|
||||
return AxiosHeaders.from(headers || {});
|
||||
};
|
||||
|
||||
const setHeaderAuthorization = (config: InternalAxiosRequestConfig, token: string): void => {
|
||||
const headers = ensureAxiosHeaders(config.headers);
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
config.headers = headers;
|
||||
};
|
||||
|
||||
const useAuthManager = ({
|
||||
apiClient,
|
||||
@@ -8,16 +56,16 @@ const useAuthManager = ({
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
}) => {
|
||||
const tokenRef = useRef(token);
|
||||
const refreshPromiseRef = useRef(null);
|
||||
}: UseAuthManagerArgs): UseAuthManagerResult => {
|
||||
const tokenRef = useRef<string | null | undefined>(token);
|
||||
const refreshPromiseRef = useRef<Promise<string> | null>(null);
|
||||
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
||||
|
||||
const refreshAccessToken = useCallback(async () => {
|
||||
const refreshAccessToken = useCallback(async (): Promise<string> => {
|
||||
console.log('[Auth] Attempting to refresh access token…');
|
||||
appDispatch({ type: 'TOKEN_REFRESH_START' });
|
||||
try {
|
||||
const { data } = await apiClient.post('/auth/refresh');
|
||||
const { data } = await apiClient.post<{ access_token?: string; tenant?: unknown }>('/auth/refresh');
|
||||
if (data?.access_token) {
|
||||
appDispatch({
|
||||
type: 'TOKEN_REFRESH_SUCCESS',
|
||||
@@ -30,7 +78,7 @@ const useAuthManager = ({
|
||||
throw new Error('Missing access token in refresh response');
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Failed to refresh access token', error);
|
||||
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null });
|
||||
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null });
|
||||
throw error;
|
||||
}
|
||||
}, [apiClient, appDispatch]);
|
||||
@@ -51,10 +99,11 @@ const useAuthManager = ({
|
||||
const requestInterceptor = apiClient.interceptors.request.use((config) => {
|
||||
const currentToken = tokenRef.current;
|
||||
if (currentToken) {
|
||||
config.headers = config.headers || {};
|
||||
if (!config.headers.Authorization) {
|
||||
config.headers.Authorization = `Bearer ${currentToken}`;
|
||||
const headers = ensureAxiosHeaders(config.headers);
|
||||
if (!headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${currentToken}`);
|
||||
}
|
||||
config.headers = headers;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
@@ -62,7 +111,8 @@ const useAuthManager = ({
|
||||
const responseInterceptor = apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const { response, config } = error;
|
||||
const axiosError = error as AxiosError & { config?: RetryableAxiosRequestConfig };
|
||||
const { response, config } = axiosError;
|
||||
if (!response || !config) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -90,13 +140,12 @@ const useAuthManager = ({
|
||||
throw new Error('No token returned from refresh');
|
||||
}
|
||||
config._retry = true;
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
setHeaderAuthorization(config, newToken);
|
||||
console.log('[Auth] Retrying original request', url);
|
||||
try {
|
||||
return await apiClient(config);
|
||||
} catch (retryError) {
|
||||
if (retryError?.response?.status === 401) {
|
||||
if ((retryError as AxiosError)?.response?.status === 401) {
|
||||
notifyApiError(retryError, 'Session expired. Please log in again.');
|
||||
}
|
||||
throw retryError;
|
||||
+29
-8
@@ -1,4 +1,25 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { MutableRefObject, useCallback, useState } from 'react';
|
||||
|
||||
type ApiClient = {
|
||||
get: (path: string) => Promise<{ data: unknown }>;
|
||||
post: (path: string, body: unknown) => Promise<{ data: unknown }>;
|
||||
patch: (path: string, body: unknown) => Promise<{ data: unknown }>;
|
||||
delete: (path: string) => Promise<{ data: unknown }>;
|
||||
};
|
||||
|
||||
interface CorrespondentEntry {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseCorrespondentsOptions {
|
||||
apiClient: ApiClient;
|
||||
notifyApiError: (error: unknown, fallback: string) => void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
tenantIdRef: MutableRefObject<string | number | null>;
|
||||
mapDocumentCaches?: (mapper: (doc: any) => any) => void;
|
||||
}
|
||||
|
||||
const useCorrespondents = ({
|
||||
apiClient,
|
||||
@@ -6,8 +27,8 @@ const useCorrespondents = ({
|
||||
setStatusMessage,
|
||||
tenantIdRef,
|
||||
mapDocumentCaches,
|
||||
}) => {
|
||||
const [correspondents, setCorrespondents] = useState([]);
|
||||
}: UseCorrespondentsOptions) => {
|
||||
const [correspondents, setCorrespondents] = useState<CorrespondentEntry[]>([]);
|
||||
|
||||
const refreshCorrespondents = useCallback(async () => {
|
||||
const requestTenantId = tenantIdRef.current;
|
||||
@@ -26,12 +47,12 @@ const useCorrespondents = ({
|
||||
}, [apiClient, notifyApiError, tenantIdRef]);
|
||||
|
||||
const handleCorrespondentUpdate = useCallback(
|
||||
async (correspondentId, changes) => {
|
||||
async (correspondentId: string | number | null | undefined, changes: { name?: string }) => {
|
||||
if (!correspondentId) {
|
||||
throw new Error('Missing correspondent identifier.');
|
||||
}
|
||||
|
||||
const payload = {};
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (typeof changes?.name?.trim === 'function') {
|
||||
const trimmed = changes.name.trim();
|
||||
if (!trimmed) {
|
||||
@@ -59,7 +80,7 @@ const useCorrespondents = ({
|
||||
);
|
||||
|
||||
const handleCorrespondentCreate = useCallback(
|
||||
async ({ name }) => {
|
||||
async ({ name }: { name?: string }) => {
|
||||
const trimmed = name?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
throw new Error('Correspondent name is required.');
|
||||
@@ -79,12 +100,12 @@ const useCorrespondents = ({
|
||||
);
|
||||
|
||||
const handleCorrespondentDelete = useCallback(
|
||||
async (correspondentId) => {
|
||||
async (correspondentId: string | number | null | undefined) => {
|
||||
if (!correspondentId) {
|
||||
throw new Error('Missing correspondent identifier.');
|
||||
}
|
||||
|
||||
const stripFromDoc = (doc) => {
|
||||
const stripFromDoc = (doc: any) => {
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return doc;
|
||||
}
|
||||
+31
-5
@@ -1,5 +1,25 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
type ApiClient = {
|
||||
post: (path: string, body?: unknown) => Promise<{ data: unknown }>;
|
||||
delete: (path: string) => Promise<{ data: unknown }>;
|
||||
};
|
||||
|
||||
interface CorrespondentOption {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseDocumentCorrespondentActionsArgs {
|
||||
apiClient: ApiClient;
|
||||
correspondents: CorrespondentOption[];
|
||||
handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null | undefined>;
|
||||
refreshCurrentFolder: () => Promise<void>;
|
||||
notifyApiError: (error: unknown, fallback: string) => void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
}
|
||||
|
||||
const useDocumentCorrespondentActions = ({
|
||||
apiClient,
|
||||
correspondents,
|
||||
@@ -7,9 +27,9 @@ const useDocumentCorrespondentActions = ({
|
||||
refreshCurrentFolder,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
}) => {
|
||||
}: UseDocumentCorrespondentActionsArgs) => {
|
||||
const correspondentLookupByName = useMemo(() => {
|
||||
const map = new Map();
|
||||
const map = new Map<string, CorrespondentOption>();
|
||||
correspondents.forEach((correspondent) => {
|
||||
if (correspondent?.name) {
|
||||
map.set(correspondent.name.toLowerCase(), correspondent);
|
||||
@@ -19,7 +39,10 @@ const useDocumentCorrespondentActions = ({
|
||||
}, [correspondents]);
|
||||
|
||||
const handleDocumentCorrespondentAttach = useCallback(
|
||||
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
||||
async (
|
||||
{ documentId, correspondentId }: { documentId?: string | number | null; correspondentId?: string | number | null },
|
||||
{ notify = true, refresh = true }: { notify?: boolean; refresh?: boolean } = {},
|
||||
) => {
|
||||
if (!documentId || !correspondentId) {
|
||||
throw new Error('Missing document or correspondent.');
|
||||
}
|
||||
@@ -45,7 +68,10 @@ const useDocumentCorrespondentActions = ({
|
||||
);
|
||||
|
||||
const handleCorrespondentRemove = useCallback(
|
||||
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
||||
async (
|
||||
{ documentId, correspondentId }: { documentId?: string | number | null; correspondentId?: string | number | null },
|
||||
{ notify = true, refresh = true }: { notify?: boolean; refresh?: boolean } = {},
|
||||
) => {
|
||||
if (!documentId || !correspondentId) {
|
||||
throw new Error('Missing document or correspondent.');
|
||||
}
|
||||
@@ -68,7 +94,7 @@ const useDocumentCorrespondentActions = ({
|
||||
);
|
||||
|
||||
const handleCorrespondentAdd = useCallback(
|
||||
async ({ document, name, input = null, option = null }) => {
|
||||
async ({ document, name, input = null, option = null }: { document?: { id?: string | number }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => {
|
||||
if (!document?.id) {
|
||||
throw new Error('Missing document for correspondent assignment.');
|
||||
}
|
||||
+53
-14
@@ -1,4 +1,38 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
type Identifier = string | number;
|
||||
type FolderIdentifier = Identifier | 'root';
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier | null;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type ApplySelectionFn = (
|
||||
keys: string[],
|
||||
options?: { anchor?: string | null; interactedKeys?: string[] },
|
||||
) => void;
|
||||
|
||||
type HandleEntrySelectionFn = (
|
||||
key: string,
|
||||
event: { preventDefault?: () => void },
|
||||
) => void;
|
||||
|
||||
interface UseDocumentDragHandlersOptions {
|
||||
selectedEntries: string[];
|
||||
selectedDocumentIds: Identifier[];
|
||||
selectedFolderIds: FolderIdentifier[];
|
||||
applySelection: ApplySelectionFn;
|
||||
handleEntrySelection: HandleEntrySelectionFn;
|
||||
documentLookup: Map<Identifier, DocumentLike>;
|
||||
setDraggedDocumentIds: (ids: Identifier[] | []) => void;
|
||||
setDraggedFolderId: (id: FolderIdentifier | null) => void;
|
||||
resolveDocumentRowKey: (id: Identifier) => string | null | undefined;
|
||||
resolveFolderRowKey: (id: FolderIdentifier) => string | null | undefined;
|
||||
documentsViewMode: string;
|
||||
}
|
||||
|
||||
const useDocumentDragHandlers = ({
|
||||
selectedEntries,
|
||||
@@ -12,8 +46,8 @@ const useDocumentDragHandlers = ({
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
documentsViewMode,
|
||||
}) => {
|
||||
const dragPreviewRef = useRef(null);
|
||||
}: UseDocumentDragHandlersOptions) => {
|
||||
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const destroyDragPreview = useCallback(() => {
|
||||
const node = dragPreviewRef.current;
|
||||
@@ -26,7 +60,7 @@ const useDocumentDragHandlers = ({
|
||||
useEffect(() => destroyDragPreview, [destroyDragPreview]);
|
||||
|
||||
const createDragPreview = useCallback(
|
||||
({ documents = [], folders = [] } = {}) => {
|
||||
({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: Array<FolderIdentifier | Identifier> } = {}) => {
|
||||
destroyDragPreview();
|
||||
|
||||
const docEntries = (documents || []).filter(Boolean);
|
||||
@@ -155,8 +189,11 @@ const useDocumentDragHandlers = ({
|
||||
);
|
||||
|
||||
const handleDocumentDragStart = useCallback(
|
||||
(event, documentOrId) => {
|
||||
const documentId = documentOrId?.id ?? (typeof documentOrId?.trim === 'function' ? documentOrId : null);
|
||||
(event: DragEvent<HTMLElement>, documentOrId: DocumentLike | Identifier | null | undefined) => {
|
||||
const documentId =
|
||||
(documentOrId as DocumentLike)?.id ?? (typeof documentOrId === 'string' || typeof documentOrId === 'number'
|
||||
? documentOrId
|
||||
: null);
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
@@ -168,12 +205,12 @@ const useDocumentDragHandlers = ({
|
||||
|
||||
const isGridView = documentsViewMode === 'grid';
|
||||
const isAlreadySelected = selectedDocumentIds.includes(documentId);
|
||||
const selection = isAlreadySelected
|
||||
const selection: Identifier[] = isAlreadySelected
|
||||
? [...selectedDocumentIds]
|
||||
: isGridView
|
||||
? [...selectedDocumentIds, documentId]
|
||||
: [documentId];
|
||||
const folderSelection = [];
|
||||
const folderSelection: FolderIdentifier[] = [];
|
||||
|
||||
if (!isAlreadySelected && !isGridView) {
|
||||
applySelection([documentKey], {
|
||||
@@ -182,7 +219,9 @@ const useDocumentDragHandlers = ({
|
||||
});
|
||||
}
|
||||
|
||||
const previewDocs = selection.map((id) => documentLookup.get(id) || null).filter(Boolean);
|
||||
const previewDocs = selection
|
||||
.map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
|
||||
.filter(Boolean);
|
||||
const previewNode = createDragPreview({
|
||||
documents: previewDocs,
|
||||
folders: folderSelection,
|
||||
@@ -230,7 +269,7 @@ const useDocumentDragHandlers = ({
|
||||
);
|
||||
|
||||
const handleDocumentDragEnd = useCallback(
|
||||
(event) => {
|
||||
(event: DragEvent<HTMLElement>) => {
|
||||
setDraggedDocumentIds([]);
|
||||
event.currentTarget.classList.remove('dragging');
|
||||
destroyDragPreview();
|
||||
@@ -240,7 +279,7 @@ const useDocumentDragHandlers = ({
|
||||
);
|
||||
|
||||
const handleFolderDragStart = useCallback(
|
||||
(event, folderId) => {
|
||||
(event: DragEvent<HTMLElement>, folderId: FolderIdentifier) => {
|
||||
if (folderId === 'root') {
|
||||
return;
|
||||
}
|
||||
@@ -248,8 +287,8 @@ const useDocumentDragHandlers = ({
|
||||
const folderKey = resolveFolderRowKey(folderId);
|
||||
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
|
||||
|
||||
let effectiveFolderSelection = selectedFolderIds;
|
||||
let effectiveDocumentSelection = selectedDocumentIds;
|
||||
let effectiveFolderSelection: FolderIdentifier[] = selectedFolderIds;
|
||||
let effectiveDocumentSelection: Identifier[] = selectedDocumentIds;
|
||||
|
||||
if (!isAlreadySelected && folderKey) {
|
||||
effectiveFolderSelection = [folderId];
|
||||
@@ -287,7 +326,7 @@ const useDocumentDragHandlers = ({
|
||||
|
||||
const previewNode = createDragPreview({
|
||||
documents: effectiveDocumentSelection
|
||||
.map((id) => documentLookup.get(id) || null)
|
||||
.map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
|
||||
.filter(Boolean),
|
||||
folders: uniqueFolders,
|
||||
});
|
||||
@@ -313,7 +352,7 @@ const useDocumentDragHandlers = ({
|
||||
);
|
||||
|
||||
const handleFolderDragEnd = useCallback(
|
||||
(event) => {
|
||||
(event?: DragEvent<HTMLElement>) => {
|
||||
if (event?.currentTarget) {
|
||||
event.currentTarget.classList.remove('dragging');
|
||||
}
|
||||
+231
-63
@@ -1,12 +1,182 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils';
|
||||
|
||||
const normalizeDocumentId = (value) => {
|
||||
type DocumentId = string | number;
|
||||
type FolderId = DocumentId | 'root';
|
||||
type NullableFolderId = FolderId | null;
|
||||
|
||||
type StatusLevel = 'success' | 'error' | 'info' | string;
|
||||
|
||||
type DocumentCacheMapper = (
|
||||
doc: DocumentLike | null | undefined,
|
||||
) => DocumentLike | null | undefined;
|
||||
|
||||
type MapDocumentCaches = (mapper: DocumentCacheMapper) => void;
|
||||
|
||||
type UpdateDocumentCaches = (
|
||||
documentId: DocumentId,
|
||||
updater: DocumentCacheMapper,
|
||||
) => void;
|
||||
|
||||
type EnsureFolderData = (
|
||||
folderId: FolderId,
|
||||
options?: { force?: boolean; includeDocuments?: boolean; prefetchDepth?: number },
|
||||
) => Promise<FolderContents>;
|
||||
|
||||
type ApplySelectedFolder = (folderId: FolderId, contents?: FolderContents | null) => void;
|
||||
|
||||
type RemoveDocumentsFromCaches = (documentIds: DocumentId[]) => void;
|
||||
|
||||
type CloseDocumentPreview = () => void;
|
||||
|
||||
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
|
||||
|
||||
type SetStatusMessage = (message: string, level?: StatusLevel) => void;
|
||||
|
||||
interface ApiClient {
|
||||
post<T = unknown>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<{ data: T }>;
|
||||
patch<T = unknown>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<{ data: T }>;
|
||||
delete<T = unknown>(url: string, config?: Record<string, unknown>): Promise<{ data: T }>;
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
id: DocumentId;
|
||||
label: string;
|
||||
color?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DocumentLike {
|
||||
id?: DocumentId;
|
||||
folder_id?: NullableFolderId;
|
||||
folder_path?: string | null;
|
||||
folder_name?: string | null;
|
||||
issued_at?: number | null;
|
||||
title?: string;
|
||||
tags?: Tag[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderContents {
|
||||
documents?: DocumentLike[];
|
||||
subfolders?: Array<{ id?: FolderId; [key: string]: unknown }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderNode {
|
||||
id: FolderId;
|
||||
parentId?: FolderId;
|
||||
children: FolderId[];
|
||||
hasChildren?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TagManager {
|
||||
normalizeLabel: (label: string) => string;
|
||||
buildPayload: (args: { label: string }) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DocumentTagExtras {
|
||||
option?: Tag | null;
|
||||
input?: { value?: string } | null;
|
||||
}
|
||||
|
||||
interface DeleteOptions {
|
||||
showMessage?: boolean;
|
||||
manageLoading?: boolean;
|
||||
}
|
||||
|
||||
interface TagAttachArgs {
|
||||
documentId?: DocumentId;
|
||||
tagId?: DocumentId;
|
||||
tag?: Tag | null;
|
||||
}
|
||||
|
||||
interface TagRemoveOptions {
|
||||
refreshTagList?: boolean;
|
||||
showMessage?: boolean;
|
||||
}
|
||||
|
||||
interface FolderDeleteOptions {
|
||||
showMessage?: boolean;
|
||||
manageLoading?: boolean;
|
||||
}
|
||||
|
||||
interface UseDocumentMutationsArgs {
|
||||
api: ApiClient;
|
||||
token?: string | null;
|
||||
documentLookup: Map<DocumentId, DocumentLike>;
|
||||
folderLabelMap: Map<FolderId, string>;
|
||||
ensureFolderData: EnsureFolderData;
|
||||
selectedFolder: FolderId;
|
||||
setSelectedFolder: Dispatch<SetStateAction<FolderId>>;
|
||||
setDocuments: Dispatch<SetStateAction<DocumentLike[]>>;
|
||||
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContents>>>;
|
||||
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | null>>;
|
||||
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
|
||||
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
|
||||
selectionOrderRef: MutableRefObject<string[] | null>;
|
||||
selectionAnchorRef: MutableRefObject<string | null>;
|
||||
setFocusedDocumentId: Dispatch<SetStateAction<DocumentId | null>>;
|
||||
focusedDocumentId: DocumentId | null;
|
||||
setFocusedRowKey: Dispatch<SetStateAction<string | null>>;
|
||||
focusedRowKey: string | null;
|
||||
notifyApiError: NotifyApiError;
|
||||
setStatusMessage: SetStatusMessage;
|
||||
setLoading: (next: boolean) => void;
|
||||
mapDocumentCaches: MapDocumentCaches;
|
||||
applySelectedFolder: ApplySelectedFolder;
|
||||
folderNodes: Map<FolderId, FolderNode>;
|
||||
setFolderNodes: Dispatch<SetStateAction<Map<FolderId, FolderNode>>>;
|
||||
removeDocumentsFromCaches: RemoveDocumentsFromCaches;
|
||||
closeDocumentPreview: CloseDocumentPreview;
|
||||
previewDocumentId?: DocumentId | null;
|
||||
refreshCurrentFolder: () => Promise<void>;
|
||||
documentsViewMode?: string;
|
||||
updateDocumentCaches: UpdateDocumentCaches;
|
||||
tagLookupById: Map<DocumentId, Tag>;
|
||||
tags: Tag[];
|
||||
refreshTags: () => Promise<void>;
|
||||
tagManager: TagManager;
|
||||
extractDocumentFromResponse?: (payload: unknown) => DocumentLike | null | undefined;
|
||||
}
|
||||
|
||||
interface UseDocumentMutationsResult {
|
||||
moveDocumentsToFolder: (
|
||||
documentIds: Array<DocumentId | DocumentLike>,
|
||||
targetFolderId?: NullableFolderId,
|
||||
) => Promise<void>;
|
||||
handleThumbnailRegeneration: (documentId: DocumentId) => Promise<void>;
|
||||
handleDocumentsDelete: (
|
||||
documentIds: DocumentId[],
|
||||
options?: DeleteOptions,
|
||||
) => Promise<boolean>;
|
||||
handleDocumentTagAdd: (
|
||||
document: DocumentLike,
|
||||
label: string,
|
||||
extras?: DocumentTagExtras | null,
|
||||
) => Promise<void>;
|
||||
handleDocumentTagAttach: (args: TagAttachArgs) => Promise<boolean>;
|
||||
handleDocumentTitleUpdate: (documentId: DocumentId, nextTitle: string) => Promise<boolean>;
|
||||
handleDocumentIssuedUpdate: (
|
||||
documentId: DocumentId,
|
||||
nextIssuedDate: number | null,
|
||||
) => Promise<boolean>;
|
||||
handleTagRemove: (
|
||||
documentId?: DocumentId,
|
||||
tagId?: DocumentId,
|
||||
options?: TagRemoveOptions,
|
||||
) => Promise<boolean>;
|
||||
handleFolderDelete: (folderId?: FolderId, options?: FolderDeleteOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const normalizeDocumentId = (value: unknown): DocumentId | null => {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'object' && value.id) {
|
||||
return value.id;
|
||||
if (typeof value === 'object' && value !== null && 'id' in value && value.id != null) {
|
||||
return value.id as DocumentId;
|
||||
}
|
||||
return value;
|
||||
return value as DocumentId;
|
||||
};
|
||||
|
||||
const useDocumentMutations = ({
|
||||
@@ -46,36 +216,34 @@ const useDocumentMutations = ({
|
||||
refreshTags,
|
||||
tagManager,
|
||||
extractDocumentFromResponse,
|
||||
}) => {
|
||||
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
|
||||
const moveDocumentsToFolder = useCallback(
|
||||
async (documentIds, targetFolderId) => {
|
||||
async (documentIds: Array<DocumentId | DocumentLike>, targetFolderId?: NullableFolderId) => {
|
||||
const uniqueIds = Array.from(
|
||||
new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean)),
|
||||
new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean) as DocumentId[]),
|
||||
);
|
||||
if (!uniqueIds.length) return;
|
||||
|
||||
const uniqueIdSet = new Set(uniqueIds);
|
||||
const target = targetFolderId === 'root' ? null : targetFolderId;
|
||||
const target = targetFolderId === 'root' ? null : targetFolderId ?? null;
|
||||
const targetLabel =
|
||||
target === null
|
||||
? DEFAULT_FOLDER_NAME
|
||||
: folderLabelMap.get(targetFolderId) || 'target folder';
|
||||
target === null ? DEFAULT_FOLDER_NAME : folderLabelMap.get(targetFolderId as FolderId) || 'target folder';
|
||||
|
||||
const movedDocs = uniqueIds
|
||||
.map((id) => {
|
||||
const doc = documentLookup.get(id);
|
||||
const doc = documentLookup.get(id) || null;
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
sourceFolderId: doc.folder_id ?? null,
|
||||
sourceFolderId: (doc.folder_id ?? null) as NullableFolderId,
|
||||
document: doc,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
.filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: DocumentLike }>;
|
||||
|
||||
const updatedDocsMap = new Map();
|
||||
const updatedDocsMap = new Map<DocumentId, DocumentLike>();
|
||||
const resolveTargetName = () => {
|
||||
if (!targetLabel) {
|
||||
return null;
|
||||
@@ -89,7 +257,7 @@ const useDocumentMutations = ({
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
const updated = {
|
||||
const updated: DocumentLike = {
|
||||
...document,
|
||||
folder_id: target,
|
||||
};
|
||||
@@ -105,13 +273,13 @@ const useDocumentMutations = ({
|
||||
updatedDocsMap.set(id, updated);
|
||||
});
|
||||
|
||||
const pruneRow = (collection) =>
|
||||
const pruneRow = (collection: string[]): string[] =>
|
||||
collection.filter((key) => {
|
||||
if (!isDocumentRowKey(key)) {
|
||||
return true;
|
||||
}
|
||||
const id = getRowId(key);
|
||||
return id ? !uniqueIdSet.has(id) : true;
|
||||
return id ? !uniqueIdSet.has(id as DocumentId) : true;
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
@@ -131,10 +299,10 @@ const useDocumentMutations = ({
|
||||
|
||||
if (updatedDocsMap.size) {
|
||||
mapDocumentCaches((doc) => {
|
||||
if (!doc || !uniqueIdSet.has(doc.id)) {
|
||||
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
|
||||
return doc;
|
||||
}
|
||||
const updated = updatedDocsMap.get(doc.id);
|
||||
const updated = updatedDocsMap.get(doc.id as DocumentId);
|
||||
if (updated) {
|
||||
return updated;
|
||||
}
|
||||
@@ -142,7 +310,7 @@ const useDocumentMutations = ({
|
||||
});
|
||||
} else {
|
||||
mapDocumentCaches((doc) => {
|
||||
if (!doc || !uniqueIdSet.has(doc.id)) {
|
||||
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
|
||||
return doc;
|
||||
}
|
||||
return { ...doc, folder_id: target };
|
||||
@@ -154,10 +322,10 @@ const useDocumentMutations = ({
|
||||
if (!Array.isArray(prev) || !prev.length) {
|
||||
return prev;
|
||||
}
|
||||
const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id));
|
||||
const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id as DocumentId));
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id)));
|
||||
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
|
||||
setFolderContents((prev) => {
|
||||
if (!prev.size) {
|
||||
return prev;
|
||||
@@ -165,7 +333,7 @@ const useDocumentMutations = ({
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
movedDocs.forEach(({ id, sourceFolderId }) => {
|
||||
const sourceKey = sourceFolderId || 'root';
|
||||
const sourceKey = (sourceFolderId || 'root') as FolderId;
|
||||
const entry = next.get(sourceKey);
|
||||
if (!entry?.documents?.length) {
|
||||
return;
|
||||
@@ -179,13 +347,14 @@ const useDocumentMutations = ({
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
setSelectedEntries((prev) => pruneRow(prev, uniqueIdSet));
|
||||
setSelectionOrder((prev) => pruneRow(prev, uniqueIdSet));
|
||||
selectionOrderRef.current = pruneRow(selectionOrderRef.current || [], uniqueIdSet);
|
||||
setSelectedEntries((prev) => pruneRow(prev));
|
||||
setSelectionOrder((prev) => pruneRow(prev));
|
||||
const nextSelectionOrder = pruneRow(selectionOrderRef.current || []);
|
||||
selectionOrderRef.current = nextSelectionOrder;
|
||||
if (
|
||||
selectionAnchorRef.current &&
|
||||
isDocumentRowKey(selectionAnchorRef.current) &&
|
||||
uniqueIdSet.has(getRowId(selectionAnchorRef.current))
|
||||
uniqueIdSet.has(getRowId(selectionAnchorRef.current) as DocumentId)
|
||||
) {
|
||||
selectionAnchorRef.current = null;
|
||||
}
|
||||
@@ -195,17 +364,17 @@ const useDocumentMutations = ({
|
||||
if (
|
||||
focusedRowKey &&
|
||||
isDocumentRowKey(focusedRowKey) &&
|
||||
uniqueIdSet.has(getRowId(focusedRowKey))
|
||||
uniqueIdSet.has(getRowId(focusedRowKey) as DocumentId)
|
||||
) {
|
||||
setFocusedRowKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetFolderId && targetFolderId !== selectedFolder) {
|
||||
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
||||
await ensureFolderData(targetFolderId as FolderId, { force: true, prefetchDepth: 1 });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to move documents.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.';
|
||||
notifyApiError(error, message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -236,7 +405,7 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const handleThumbnailRegeneration = useCallback(
|
||||
async (documentId) => {
|
||||
async (documentId: DocumentId) => {
|
||||
if (!token) {
|
||||
setStatusMessage('Log in to manage assets.', 'error');
|
||||
return;
|
||||
@@ -249,7 +418,7 @@ const useDocumentMutations = ({
|
||||
setStatusMessage('Document re-analysis queued.', 'info');
|
||||
await refreshCurrentFolder();
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to request thumbnail generation.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to request thumbnail generation.';
|
||||
notifyApiError(error, message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -259,7 +428,7 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const handleDocumentsDelete = useCallback(
|
||||
async (documentIds, { showMessage = true, manageLoading = true } = {}) => {
|
||||
async (documentIds: DocumentId[], { showMessage = true, manageLoading = true }: DeleteOptions = {}) => {
|
||||
if (!documentIds || documentIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -274,13 +443,11 @@ const useDocumentMutations = ({
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)),
|
||||
);
|
||||
await Promise.all(documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)));
|
||||
|
||||
removeDocumentsFromCaches(documentIds);
|
||||
|
||||
if (documentIds.includes(previewDocumentId)) {
|
||||
if (previewDocumentId && documentIds.includes(previewDocumentId)) {
|
||||
closeDocumentPreview();
|
||||
}
|
||||
|
||||
@@ -290,7 +457,7 @@ const useDocumentMutations = ({
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to delete documents.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete documents.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
} finally {
|
||||
@@ -312,7 +479,7 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const handleDocumentTitleUpdate = useCallback(
|
||||
async (documentId, nextTitle) => {
|
||||
async (documentId: DocumentId, nextTitle: string) => {
|
||||
const trimmed = nextTitle?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Document title cannot be empty.', 'error');
|
||||
@@ -334,7 +501,7 @@ const useDocumentMutations = ({
|
||||
setStatusMessage('Document title updated.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to update document title.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update document title.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
} finally {
|
||||
@@ -345,7 +512,7 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const handleDocumentIssuedUpdate = useCallback(
|
||||
async (documentId, nextIssuedDate) => {
|
||||
async (documentId: DocumentId, nextIssuedDate: number | null) => {
|
||||
setLoading(true);
|
||||
const payload = { issued_at: nextIssuedDate || null };
|
||||
try {
|
||||
@@ -363,7 +530,7 @@ const useDocumentMutations = ({
|
||||
setStatusMessage(message, 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to update issued date.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update issued date.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
} finally {
|
||||
@@ -374,26 +541,23 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const handleDocumentTagAdd = useCallback(
|
||||
async (document, label, extras = null) => {
|
||||
async (document: DocumentLike, label: string, extras: DocumentTagExtras | null = null) => {
|
||||
const normalizedLabel = tagManager.normalizeLabel(label);
|
||||
const optionCandidate =
|
||||
extras && typeof extras === 'object' && 'option' in extras ? extras.option : null;
|
||||
const input =
|
||||
extras && typeof extras === 'object' && 'input' in extras ? extras.input : null;
|
||||
const optionCandidate = extras?.option ?? null;
|
||||
const input = extras?.input ?? null;
|
||||
|
||||
let tag = null;
|
||||
let tag: Tag | null = null;
|
||||
if (optionCandidate && optionCandidate.id) {
|
||||
tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate;
|
||||
}
|
||||
if (!tag) {
|
||||
tag =
|
||||
tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
|
||||
tag = tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
|
||||
}
|
||||
try {
|
||||
if (!tag) {
|
||||
const payload = tagManager.buildPayload({ label: normalizedLabel });
|
||||
const { data } = await api.post('/tags', payload);
|
||||
tag = data;
|
||||
tag = data as Tag;
|
||||
await refreshTags();
|
||||
}
|
||||
await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
|
||||
@@ -410,12 +574,12 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const handleDocumentTagAttach = useCallback(
|
||||
async ({ documentId, tagId, tag: tagData = null }) => {
|
||||
async ({ documentId, tagId, tag: tagData = null }: TagAttachArgs) => {
|
||||
if (!documentId || !tagId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolveTagForCache = () => {
|
||||
const resolveTagForCache = (): Tag | null => {
|
||||
const lookupTag = tagLookupById.get(tagId);
|
||||
const source = lookupTag ?? tagData;
|
||||
if (!source || source.id == null || typeof source.label?.trim !== 'function') {
|
||||
@@ -424,7 +588,7 @@ const useDocumentMutations = ({
|
||||
return {
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
color: Object.prototype.hasOwnProperty.call(source, 'color') ? source.color : null,
|
||||
color: Object.prototype.hasOwnProperty.call(source, 'color') ? (source as Tag).color ?? null : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -450,7 +614,7 @@ const useDocumentMutations = ({
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to assign tag.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
}
|
||||
@@ -467,16 +631,16 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const applyTagRemovalToCaches = useCallback(
|
||||
(documentId, tagId) => {
|
||||
(documentId?: DocumentId, tagId?: DocumentId) => {
|
||||
if (!documentId || !tagId) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (!Array.isArray(doc.tags)) {
|
||||
if (!doc || !Array.isArray(doc.tags)) {
|
||||
return doc;
|
||||
}
|
||||
const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
|
||||
const nextTags = doc.tags.filter((tagEntry) => tagEntry.id !== tagId);
|
||||
if (nextTags.length === doc.tags.length) {
|
||||
return doc;
|
||||
}
|
||||
@@ -487,7 +651,11 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const handleTagRemove = useCallback(
|
||||
async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => {
|
||||
async (
|
||||
documentId?: DocumentId,
|
||||
tagId?: DocumentId,
|
||||
{ refreshTagList = true, showMessage = true }: TagRemoveOptions = {},
|
||||
) => {
|
||||
if (!documentId || !tagId) {
|
||||
return false;
|
||||
}
|
||||
@@ -503,7 +671,7 @@ const useDocumentMutations = ({
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to remove tag.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
}
|
||||
@@ -512,7 +680,7 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const handleFolderDelete = useCallback(
|
||||
async (folderId, { showMessage = true, manageLoading = true } = {}) => {
|
||||
async (folderId?: FolderId, { showMessage = true, manageLoading = true }: FolderDeleteOptions = {}) => {
|
||||
if (!token) {
|
||||
if (showMessage) {
|
||||
setStatusMessage('Log in to manage folders.', 'error');
|
||||
@@ -589,7 +757,7 @@ const useDocumentMutations = ({
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to delete folder.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete folder.';
|
||||
notifyApiError(error, message);
|
||||
if (showMessage) {
|
||||
setStatusMessage(message, 'error');
|
||||
+51
-9
@@ -1,5 +1,47 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface TagRecord {
|
||||
id?: Identifier;
|
||||
label: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
post: <T = { data: unknown }>(path: string, payload: unknown) => Promise<{ data: T } | T>;
|
||||
}
|
||||
|
||||
interface TagManager {
|
||||
buildPayload: (input: { label: string }) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface UseDocumentTaggingArgs {
|
||||
apiClient: ApiClient;
|
||||
tags: TagRecord[];
|
||||
tagManager: TagManager;
|
||||
refreshTags: () => Promise<void> | void;
|
||||
refreshCurrentFolder: () => Promise<void> | void;
|
||||
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
|
||||
notifyApiError: (error: unknown, message: string) => void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
setLoading: (state: boolean) => void;
|
||||
}
|
||||
|
||||
interface BulkTagOperationArgs {
|
||||
labels: string[];
|
||||
action: 'add' | 'remove';
|
||||
documentIds?: Identifier[];
|
||||
}
|
||||
|
||||
interface BulkTagOperationResult {
|
||||
ok: boolean;
|
||||
reason?: 'no-labels' | 'no-selection' | 'tag-missing' | 'no-tags' | 'request-failed';
|
||||
label?: string;
|
||||
tagCount?: number;
|
||||
docsCount?: number;
|
||||
}
|
||||
|
||||
const useDocumentTagging = ({
|
||||
apiClient,
|
||||
tags,
|
||||
@@ -10,9 +52,9 @@ const useDocumentTagging = ({
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
}) => {
|
||||
}: UseDocumentTaggingArgs) => {
|
||||
const bulkTagOperation = useCallback(
|
||||
async ({ labels, action, documentIds }) => {
|
||||
async ({ labels, action, documentIds }: BulkTagOperationArgs): Promise<BulkTagOperationResult> => {
|
||||
const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0);
|
||||
if (!normalized.length) {
|
||||
return { ok: false, reason: 'no-labels' };
|
||||
@@ -22,7 +64,7 @@ const useDocumentTagging = ({
|
||||
return { ok: false, reason: 'no-selection' };
|
||||
}
|
||||
|
||||
let tagIds = [];
|
||||
let tagIds: Identifier[] = [];
|
||||
|
||||
if (action === 'remove') {
|
||||
const missing = normalized.find(
|
||||
@@ -41,13 +83,13 @@ const useDocumentTagging = ({
|
||||
setLoading(true);
|
||||
try {
|
||||
if (action === 'add') {
|
||||
const createdIds = [];
|
||||
const createdIds: Identifier[] = [];
|
||||
for (const label of normalized) {
|
||||
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
||||
if (!tag) {
|
||||
const payload = tagManager.buildPayload({ label });
|
||||
const { data } = await apiClient.post('/tags', payload);
|
||||
tag = data;
|
||||
const response = await apiClient.post<{ id?: Identifier; label: string }>('/tags', payload);
|
||||
tag = 'data' in response ? response.data : response;
|
||||
await refreshTags();
|
||||
}
|
||||
createdIds.push(tag.id);
|
||||
@@ -97,7 +139,7 @@ const useDocumentTagging = ({
|
||||
);
|
||||
|
||||
const handleBulkTagAddFromDetail = useCallback(
|
||||
async ({ label, input, documentIds }) => {
|
||||
async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const trimmed = label?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Enter a tag label.', 'error');
|
||||
@@ -130,7 +172,7 @@ const useDocumentTagging = ({
|
||||
);
|
||||
|
||||
const handleBulkTagRemoveFromDetail = useCallback(
|
||||
async ({ label, input, documentIds }) => {
|
||||
async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const trimmed = label?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Enter a tag label to remove.', 'error');
|
||||
@@ -163,7 +205,7 @@ const useDocumentTagging = ({
|
||||
);
|
||||
|
||||
const handleBulkSelectionReanalyze = useCallback(
|
||||
async (documentIdsOverride = null) => {
|
||||
async (documentIdsOverride: Identifier[] | null = null) => {
|
||||
const targetIds = resolveTargetDocumentIds(documentIdsOverride);
|
||||
if (!targetIds.length) {
|
||||
setStatusMessage('Select documents before requesting re-analysis.', 'error');
|
||||
+134
-37
@@ -1,8 +1,76 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import useFileDrop from './useFileDrop';
|
||||
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
|
||||
|
||||
const mapFilesToEntries = (filesInput) => {
|
||||
type Identifier = string | number;
|
||||
type FolderId = Identifier | 'root' | null;
|
||||
|
||||
type FileEntry = {
|
||||
file: File;
|
||||
segments: string[];
|
||||
};
|
||||
|
||||
type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error';
|
||||
|
||||
type UploadQueueItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number | null;
|
||||
folderId: FolderId;
|
||||
status: UploadStatus;
|
||||
error: string | null;
|
||||
code: number | null;
|
||||
document: unknown;
|
||||
conflictDocumentId: Identifier | null;
|
||||
};
|
||||
|
||||
interface UploadResponse {
|
||||
reused?: boolean;
|
||||
document?: unknown;
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
post<T = UploadResponse>(url: string, payload: unknown): Promise<{ data: T; status?: number }>;
|
||||
get<T = { document?: unknown }>(url: string): Promise<{ data: T }>;
|
||||
}
|
||||
|
||||
type DropOverlayState = {
|
||||
active: boolean;
|
||||
folderName: string;
|
||||
};
|
||||
|
||||
type FileSystemEntryLike = FileSystemFileEntryLike | FileSystemDirectoryEntryLike;
|
||||
|
||||
type ExtendedDataTransferItem = DataTransferItem & {
|
||||
webkitGetAsEntry?: () => FileSystemEntryLike | null;
|
||||
};
|
||||
|
||||
interface FileSystemDirectoryReaderLike {
|
||||
readEntries: (
|
||||
successCallback: (entries: FileSystemEntryLike[]) => void,
|
||||
errorCallback: (error: DOMException) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface FileSystemFileEntryLike {
|
||||
isFile: true;
|
||||
isDirectory: false;
|
||||
name: string;
|
||||
file: (
|
||||
successCallback: (file: File) => void,
|
||||
errorCallback: (error: DOMException) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface FileSystemDirectoryEntryLike {
|
||||
isFile: false;
|
||||
isDirectory: true;
|
||||
name: string;
|
||||
createReader: () => FileSystemDirectoryReaderLike;
|
||||
}
|
||||
|
||||
const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] => {
|
||||
if (!filesInput) {
|
||||
return [];
|
||||
}
|
||||
@@ -10,7 +78,7 @@ const mapFilesToEntries = (filesInput) => {
|
||||
return files
|
||||
.filter(Boolean)
|
||||
.map((file) => {
|
||||
const relativePath = file?.webkitRelativePath ?? '';
|
||||
const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
|
||||
const segments = relativePath
|
||||
? relativePath
|
||||
.split('/')
|
||||
@@ -21,6 +89,35 @@ const mapFilesToEntries = (filesInput) => {
|
||||
});
|
||||
};
|
||||
|
||||
interface UseDocumentUploadsArgs {
|
||||
apiClient: ApiClient;
|
||||
token?: string | null;
|
||||
selectedFolder?: FolderId;
|
||||
currentFolderName?: string | null;
|
||||
ensureFolderData: (folderId: FolderId, options?: { force?: boolean; prefetchDepth?: number }) => Promise<void>;
|
||||
refreshCurrentFolder: () => Promise<void>;
|
||||
setLoading: (state: boolean) => void;
|
||||
shellRef: MutableRefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
interface UseDocumentUploadsResult {
|
||||
dropOverlayState: DropOverlayState;
|
||||
setDropOverlayState: Dispatch<SetStateAction<DropOverlayState>>;
|
||||
dragCounterRef: MutableRefObject<number>;
|
||||
handleFileDrop: (dataTransfer: DataTransfer, targetFolderId?: FolderId) => Promise<void>;
|
||||
handleFileSelection: (files?: FileList | null, targetFolderId?: FolderId) => Promise<void>;
|
||||
uploadFile: (file: File, targetFolderId: FolderId) => Promise<{
|
||||
document: unknown;
|
||||
duplicate: boolean;
|
||||
statusCode: number | null;
|
||||
conflictDocumentId: Identifier | null;
|
||||
}>;
|
||||
extractFilesFromDataTransfer: (dataTransfer: DataTransfer) => Promise<FileEntry[]>;
|
||||
resetUploadsState: () => void;
|
||||
uploadQueue: UploadQueueItem[];
|
||||
clearUploadQueue: () => void;
|
||||
}
|
||||
|
||||
const useDocumentUploads = ({
|
||||
apiClient,
|
||||
token,
|
||||
@@ -30,18 +127,18 @@ const useDocumentUploads = ({
|
||||
refreshCurrentFolder,
|
||||
setLoading,
|
||||
shellRef,
|
||||
}) => {
|
||||
const [dropOverlayState, setDropOverlayState] = useState({
|
||||
}: UseDocumentUploadsArgs): UseDocumentUploadsResult => {
|
||||
const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({
|
||||
active: false,
|
||||
folderName: DEFAULT_FOLDER_NAME,
|
||||
folderName: currentFolderName || DEFAULT_FOLDER_NAME,
|
||||
});
|
||||
const dragCounterRef = useRef(0);
|
||||
const folderPathCacheRef = useRef(new Map());
|
||||
const folderPathCacheRef = useRef<Map<string, FolderId>>(new Map());
|
||||
const queueIdRef = useRef(0);
|
||||
const [uploadQueue, setUploadQueue] = useState([]);
|
||||
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file, targetFolderId) => {
|
||||
async (file: File, targetFolderId: FolderId) => {
|
||||
if (!file || file.size === 0) {
|
||||
return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null };
|
||||
}
|
||||
@@ -62,14 +159,14 @@ const useDocumentUploads = ({
|
||||
statusCode: status ?? (duplicate ? 200 : 201),
|
||||
conflictDocumentId: null,
|
||||
};
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 409) {
|
||||
const conflictId = error.response?.data?.details?.conflict_document_id ?? null;
|
||||
let conflictDocument = null;
|
||||
if (conflictId) {
|
||||
try {
|
||||
const { data } = await apiClient.get(`/documents/${conflictId}`);
|
||||
conflictDocument = data?.document ?? data ?? null;
|
||||
conflictDocument = (data as any)?.document ?? data ?? null;
|
||||
} catch (fetchError) {
|
||||
console.warn('[Uploads] failed to fetch conflict document', fetchError);
|
||||
}
|
||||
@@ -89,7 +186,7 @@ const useDocumentUploads = ({
|
||||
[apiClient],
|
||||
);
|
||||
|
||||
const appendQueueItems = useCallback((entries, targetFolderId) => {
|
||||
const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => {
|
||||
const baseId = Date.now();
|
||||
const items = entries.map(({ file }) => {
|
||||
queueIdRef.current += 1;
|
||||
@@ -98,12 +195,12 @@ const useDocumentUploads = ({
|
||||
name: file?.name || 'Unnamed file',
|
||||
size: file?.size ?? null,
|
||||
folderId: targetFolderId ?? selectedFolder ?? 'root',
|
||||
status: 'pending',
|
||||
status: 'pending' as UploadStatus,
|
||||
error: null,
|
||||
code: null,
|
||||
document: null,
|
||||
conflictDocumentId: null,
|
||||
};
|
||||
} satisfies UploadQueueItem;
|
||||
});
|
||||
if (items.length) {
|
||||
setUploadQueue((current) => [...current, ...items]);
|
||||
@@ -111,7 +208,7 @@ const useDocumentUploads = ({
|
||||
return items;
|
||||
}, [selectedFolder]);
|
||||
|
||||
const updateQueueItem = useCallback((id, patch) => {
|
||||
const updateQueueItem = useCallback((id: string, patch: Partial<UploadQueueItem>) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
@@ -121,7 +218,7 @@ const useDocumentUploads = ({
|
||||
}, []);
|
||||
|
||||
const ensureFolderPathOnServer = useCallback(
|
||||
async (baseFolderId, segments) => {
|
||||
async (baseFolderId: FolderId, segments: string[]): Promise<FolderId> => {
|
||||
const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
|
||||
if (trimmedSegments.length === 0) {
|
||||
return baseFolderId ?? null;
|
||||
@@ -130,7 +227,7 @@ const useDocumentUploads = ({
|
||||
const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`;
|
||||
const cache = folderPathCacheRef.current;
|
||||
if (cache.has(cacheKey)) {
|
||||
return cache.get(cacheKey);
|
||||
return cache.get(cacheKey) ?? null;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
@@ -145,21 +242,21 @@ const useDocumentUploads = ({
|
||||
[apiClient],
|
||||
);
|
||||
|
||||
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
|
||||
const extractFilesFromDataTransfer = useCallback(async (dataTransfer: DataTransfer) => {
|
||||
if (!dataTransfer) {
|
||||
throw new Error('No drop payload found.');
|
||||
}
|
||||
|
||||
const items = Array.from(dataTransfer.items || []);
|
||||
const items = Array.from(dataTransfer.items || []) as ExtendedDataTransferItem[];
|
||||
console.info('[Uploads] drop start', {
|
||||
items: items.length,
|
||||
files: (dataTransfer.files || []).length,
|
||||
});
|
||||
|
||||
const results = [];
|
||||
const results: FileEntry[] = [];
|
||||
const seenKeys = new Set();
|
||||
|
||||
const pushFile = (file, ancestors = []) => {
|
||||
const pushFile = (file?: File | null, ancestors: string[] = []) => {
|
||||
if (!file) return;
|
||||
const segments = (ancestors || []).filter(Boolean);
|
||||
const key = `${segments.join('/')}/${file.name}:${file.size}`;
|
||||
@@ -170,11 +267,11 @@ const useDocumentUploads = ({
|
||||
results.push({ file, segments });
|
||||
};
|
||||
|
||||
const readAllEntries = async (reader) => {
|
||||
const entries = [];
|
||||
let batch = [];
|
||||
const readAllEntries = async (reader: FileSystemDirectoryReaderLike) => {
|
||||
const entries: FileSystemEntryLike[] = [];
|
||||
let batch: FileSystemEntryLike[] = [];
|
||||
do {
|
||||
batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
|
||||
batch = await new Promise<FileSystemEntryLike[]>((resolve, reject) => reader.readEntries(resolve, reject));
|
||||
if (batch.length) {
|
||||
entries.push(...batch);
|
||||
}
|
||||
@@ -182,15 +279,15 @@ const useDocumentUploads = ({
|
||||
return entries;
|
||||
};
|
||||
|
||||
const walkEntry = async (entry, ancestors = []) => {
|
||||
const walkEntry = async (entry: FileSystemEntryLike | null, ancestors: string[] = []) => {
|
||||
if (!entry) return;
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise((resolve, reject) => {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
try {
|
||||
entry.file(resolve, reject);
|
||||
(entry as FileSystemFileEntryLike).file(resolve, reject);
|
||||
} catch (error) {
|
||||
console.warn('[Uploads] entry.file failed', error);
|
||||
reject(error);
|
||||
reject(error as Error);
|
||||
}
|
||||
});
|
||||
pushFile(file, ancestors);
|
||||
@@ -198,7 +295,7 @@ const useDocumentUploads = ({
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
|
||||
const reader = entry.createReader();
|
||||
const reader = (entry as FileSystemDirectoryEntryLike).createReader();
|
||||
const entries = await readAllEntries(reader);
|
||||
for (const child of entries) {
|
||||
await walkEntry(child, nextAncestors);
|
||||
@@ -212,7 +309,7 @@ const useDocumentUploads = ({
|
||||
|
||||
const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
|
||||
if (fileFromItem) {
|
||||
const relativePath = fileFromItem?.webkitRelativePath ?? '';
|
||||
const relativePath = (fileFromItem as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
|
||||
const segments = relativePath
|
||||
? relativePath
|
||||
.split('/')
|
||||
@@ -242,7 +339,7 @@ const useDocumentUploads = ({
|
||||
|
||||
Array.from(dataTransfer.files || []).forEach((file) => {
|
||||
if (!file) return;
|
||||
const relativePath = file?.webkitRelativePath ?? '';
|
||||
const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
|
||||
const segments = relativePath
|
||||
? relativePath
|
||||
.split('/')
|
||||
@@ -322,7 +419,7 @@ const useDocumentUploads = ({
|
||||
updateQueueItem(queueItem.id, patch);
|
||||
Object.assign(queueItem, patch);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (queueItem) {
|
||||
const patch = {
|
||||
status: 'error',
|
||||
@@ -345,7 +442,7 @@ const useDocumentUploads = ({
|
||||
) {
|
||||
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const message = error.message || 'Failed to upload files.';
|
||||
queueItems.forEach((item) => {
|
||||
if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') {
|
||||
@@ -378,8 +475,8 @@ const useDocumentUploads = ({
|
||||
);
|
||||
|
||||
const handleFileDrop = useCallback(
|
||||
async (dataTransfer, targetFolderId) => {
|
||||
let extracted;
|
||||
async (dataTransfer: DataTransfer, targetFolderId?: FolderId) => {
|
||||
let extracted: FileEntry[];
|
||||
try {
|
||||
extracted = await extractFilesFromDataTransfer(dataTransfer);
|
||||
} catch (error) {
|
||||
@@ -393,7 +490,7 @@ const useDocumentUploads = ({
|
||||
);
|
||||
|
||||
const handleFileSelection = useCallback(
|
||||
async (files, targetFolderId) => {
|
||||
async (files?: FileList | null, targetFolderId?: FolderId) => {
|
||||
const entries = mapFilesToEntries(files);
|
||||
await uploadFileEntries(entries, targetFolderId);
|
||||
},
|
||||
@@ -434,7 +531,7 @@ const useDocumentUploads = ({
|
||||
resetUploadsState,
|
||||
uploadQueue,
|
||||
clearUploadQueue,
|
||||
};
|
||||
} satisfies UseDocumentUploadsResult;
|
||||
};
|
||||
|
||||
export default useDocumentUploads;
|
||||
+23
-7
@@ -1,17 +1,33 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Dispatch, SetStateAction, useCallback, useState } from 'react';
|
||||
|
||||
const useDocuments = ({ setSearchResults, setFolderContents }) => {
|
||||
const [documents, setDocuments] = useState([]);
|
||||
interface DocumentLike {
|
||||
id?: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderContentsEntry {
|
||||
documents?: DocumentLike[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseDocumentsOptions {
|
||||
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | null | undefined>>;
|
||||
setFolderContents: Dispatch<SetStateAction<Map<string, FolderContentsEntry>>>;
|
||||
}
|
||||
|
||||
const useDocuments = ({ setSearchResults, setFolderContents }: UseDocumentsOptions) => {
|
||||
const [documents, setDocuments] = useState<DocumentLike[]>([]);
|
||||
|
||||
const mapDocumentCaches = useCallback(
|
||||
(mapper) => {
|
||||
(mapper: (doc: DocumentLike) => DocumentLike | undefined) => {
|
||||
if (typeof mapper !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyToList = (list) => {
|
||||
const applyToList = (list?: DocumentLike[] | null) => {
|
||||
let changed = false;
|
||||
const next = list.map((doc) => {
|
||||
const safeList = Array.isArray(list) ? list : [];
|
||||
const next = safeList.map((doc) => {
|
||||
const updated = mapper(doc);
|
||||
if (updated === undefined || updated === doc) {
|
||||
return doc;
|
||||
@@ -19,7 +35,7 @@ const useDocuments = ({ setSearchResults, setFolderContents }) => {
|
||||
changed = true;
|
||||
return updated;
|
||||
});
|
||||
return changed ? next : list;
|
||||
return changed ? next : safeList;
|
||||
};
|
||||
|
||||
setDocuments((prev) => applyToList(prev));
|
||||
+18
-2
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
matchPath,
|
||||
useLocation,
|
||||
@@ -57,6 +57,22 @@ const EntryType = Object.freeze({
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
interface UseDocumentsWorkspaceOptions {
|
||||
documentsViewMode?: string;
|
||||
documentsSortField?: string;
|
||||
documentsSortDirection?: string;
|
||||
documentsSortFieldRef?: MutableRefObject<string>;
|
||||
documentsSortDirectionRef?: MutableRefObject<string>;
|
||||
onDocumentsViewModeChange?: (mode: string) => void;
|
||||
onDocumentsSortFieldChange?: (field: string) => void;
|
||||
onDocumentsSortDirectionToggle?: () => void;
|
||||
searchIncludeDescendants?: boolean;
|
||||
onToggleSearchIncludeDescendants?: () => void;
|
||||
onSetSearchIncludeDescendants?: (value: boolean) => void;
|
||||
sortRefreshReadyRef?: MutableRefObject<boolean>;
|
||||
handleDeskExit?: () => void;
|
||||
}
|
||||
|
||||
const useDocumentsWorkspace = ({
|
||||
documentsViewMode = 'list',
|
||||
documentsSortField = DEFAULT_SORT_FIELD,
|
||||
@@ -71,7 +87,7 @@ const useDocumentsWorkspace = ({
|
||||
onSetSearchIncludeDescendants,
|
||||
sortRefreshReadyRef,
|
||||
handleDeskExit,
|
||||
} = {}) => {
|
||||
}: UseDocumentsWorkspaceOptions = {}) => {
|
||||
const handleDocumentsViewModeChange = onDocumentsViewModeChange || noop;
|
||||
const handleDocumentsSortFieldChange = onDocumentsSortFieldChange || noop;
|
||||
const handleDocumentsSortDirectionToggle = onDocumentsSortDirectionToggle || noop;
|
||||
+23
-6
@@ -1,4 +1,21 @@
|
||||
import { useEffect } from 'react';
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
|
||||
interface DropOverlayState {
|
||||
active: boolean;
|
||||
folderName: string | null;
|
||||
}
|
||||
|
||||
interface UseFileDropOptions {
|
||||
shellRef: MutableRefObject<HTMLElement | null>;
|
||||
token?: string | null;
|
||||
currentFolderName: string | null;
|
||||
selectedFolder: string | null;
|
||||
handleFileDrop: (dataTransfer: DataTransfer, folderId: string | null) => Promise<void>;
|
||||
hasFiles: (event: DragEvent) => boolean;
|
||||
defaultFolderName: string;
|
||||
dragCounterRef: MutableRefObject<number>;
|
||||
setDropOverlayState: (updater: ((prev: DropOverlayState) => DropOverlayState) | DropOverlayState) => void;
|
||||
}
|
||||
|
||||
const useFileDrop = ({
|
||||
shellRef,
|
||||
@@ -10,7 +27,7 @@ const useFileDrop = ({
|
||||
defaultFolderName,
|
||||
dragCounterRef,
|
||||
setDropOverlayState,
|
||||
}) => {
|
||||
}: UseFileDropOptions) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -19,20 +36,20 @@ const useFileDrop = ({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleDragEnter = (event) => {
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragCounterRef.current += 1;
|
||||
setDropOverlayState({ active: true, folderName: currentFolderName });
|
||||
};
|
||||
|
||||
const handleDragOver = (event) => {
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
|
||||
const handleDragLeave = (event) => {
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
if (!hasFiles(event)) return;
|
||||
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
||||
if (dragCounterRef.current === 0) {
|
||||
@@ -40,7 +57,7 @@ const useFileDrop = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (event) => {
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragCounterRef.current = 0;
|
||||
+106
-20
@@ -1,6 +1,88 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
|
||||
|
||||
type FolderId = string | number;
|
||||
type FolderKey = FolderId | 'root';
|
||||
|
||||
interface FolderNode {
|
||||
id: FolderKey;
|
||||
name?: string;
|
||||
parentId?: FolderKey | null;
|
||||
children: FolderId[];
|
||||
expanded?: boolean;
|
||||
loaded?: boolean;
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
interface FolderContentsState {
|
||||
folder?: { id: FolderKey; name?: string };
|
||||
subfolders?: any[];
|
||||
documents?: any[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
patch: (url: string, data?: unknown) => Promise<any>;
|
||||
post: (url: string, data?: unknown) => Promise<{ data: any }>;
|
||||
delete: (url: string) => Promise<any>;
|
||||
}
|
||||
|
||||
interface EnsureFolderOptions {
|
||||
force?: boolean;
|
||||
includeDocuments?: boolean;
|
||||
prefetchDepth?: number;
|
||||
}
|
||||
|
||||
interface LoadFolderOptions {
|
||||
showLoading?: boolean;
|
||||
preserveSearch?: boolean;
|
||||
}
|
||||
|
||||
interface SelectFolderOptions {
|
||||
replace?: boolean;
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
interface FolderClickHandlers {
|
||||
onToggle: (folderId: FolderKey) => Promise<void>;
|
||||
onSelect: (folderId: FolderKey, options?: SelectFolderOptions) => Promise<void>;
|
||||
onDrop: (event: DragEvent<HTMLElement>, folderId: FolderKey) => Promise<void>;
|
||||
onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => void;
|
||||
onDragLeave: (event: DragEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
interface UseFolderTreeActionsOptions {
|
||||
api: ApiClient;
|
||||
token?: string | null;
|
||||
folderNodes: Map<FolderKey, FolderNode>;
|
||||
setFolderNodes: (updater: (prev: Map<FolderKey, FolderNode>) => Map<FolderKey, FolderNode>) => void;
|
||||
selectedFolder: FolderKey;
|
||||
setSelectedFolder: (folderId: FolderKey) => void;
|
||||
ensureFolderData: (folderId: FolderKey, options?: EnsureFolderOptions) => Promise<any>;
|
||||
ensureFolderAncestorsLoaded: (folderId: FolderKey) => Promise<void>;
|
||||
expandFolderAncestors: (folderId: FolderKey) => void;
|
||||
applySelectedFolder: (folderId: FolderKey, contents: any) => void;
|
||||
notifyApiError: (error: unknown, message?: string) => void;
|
||||
setStatusMessage: (message: string, level?: string) => void;
|
||||
setLoading: (value: boolean) => void;
|
||||
setFolderContents: (
|
||||
updater: (prev: Map<FolderKey, FolderContentsState>) => Map<FolderKey, FolderContentsState>,
|
||||
) => void;
|
||||
setCurrentFolder: (updater: (prev: any) => any) => void;
|
||||
setSearchResults: (value: any) => void;
|
||||
isFilterActive: boolean;
|
||||
navigate?: (path: string, options?: { replace?: boolean }) => void;
|
||||
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise<void> | void;
|
||||
moveDocumentsToFolder: (docIds: FolderId[], folderId: FolderKey) => Promise<void>;
|
||||
draggedDocumentIds: FolderId[];
|
||||
draggedFolderId: FolderKey | null;
|
||||
setDraggedDocumentIds: (ids: FolderId[]) => void;
|
||||
setDraggedFolderId: (id: FolderKey | null) => void;
|
||||
isInvalidFolderDrop: (sourceFolderId: FolderKey, targetFolderId: FolderKey) => boolean;
|
||||
setCreatingFolder: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const useFolderTreeActions = ({
|
||||
api,
|
||||
token,
|
||||
@@ -28,9 +110,9 @@ const useFolderTreeActions = ({
|
||||
setDraggedFolderId,
|
||||
isInvalidFolderDrop,
|
||||
setCreatingFolder,
|
||||
}) => {
|
||||
}: UseFolderTreeActionsOptions) => {
|
||||
const moveFolder = useCallback(
|
||||
async (folderId, targetFolderId) => {
|
||||
async (folderId: FolderKey, targetFolderId: FolderKey | null | undefined) => {
|
||||
const node = folderNodes.get(folderId);
|
||||
if (!node) {
|
||||
setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error');
|
||||
@@ -95,9 +177,11 @@ const useFolderTreeActions = ({
|
||||
});
|
||||
|
||||
const refreshTargets = new Set([previousParentKey, targetKey]);
|
||||
for (const key of refreshTargets) {
|
||||
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(refreshTargets).map((key) =>
|
||||
ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }),
|
||||
),
|
||||
);
|
||||
|
||||
if (selectedFolder === folderId) {
|
||||
await ensureFolderData(folderId, { force: true, prefetchDepth: 1 });
|
||||
@@ -110,9 +194,11 @@ const useFolderTreeActions = ({
|
||||
notifyApiError(error, message);
|
||||
|
||||
const refreshTargets = new Set([previousParentKey, targetKey]);
|
||||
for (const key of refreshTargets) {
|
||||
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(refreshTargets).map((key) =>
|
||||
ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -128,7 +214,7 @@ const useFolderTreeActions = ({
|
||||
);
|
||||
|
||||
const loadFolder = useCallback(
|
||||
async (folderId, { showLoading = true, preserveSearch = false } = {}) => {
|
||||
async (folderId: FolderKey | null | undefined, { showLoading = true, preserveSearch = false }: LoadFolderOptions = {}) => {
|
||||
const targetId = folderId || 'root';
|
||||
setSelectedFolder(targetId);
|
||||
await ensureFolderAncestorsLoaded(targetId);
|
||||
@@ -170,7 +256,7 @@ const useFolderTreeActions = ({
|
||||
);
|
||||
|
||||
const selectFolder = useCallback(
|
||||
async (folderId, { replace = false, immediate = false } = {}) => {
|
||||
async (folderId: FolderKey | null | undefined, { replace = false, immediate = false }: SelectFolderOptions = {}) => {
|
||||
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
|
||||
|
||||
await ensureFolderAncestorsLoaded(targetId);
|
||||
@@ -196,7 +282,7 @@ const useFolderTreeActions = ({
|
||||
);
|
||||
|
||||
const handleFolderRename = useCallback(
|
||||
async (folderId, nextName) => {
|
||||
async (folderId: FolderKey, nextName: string) => {
|
||||
if (!token) {
|
||||
setStatusMessage('Log in to rename folders.', 'error');
|
||||
return false;
|
||||
@@ -257,7 +343,7 @@ const useFolderTreeActions = ({
|
||||
);
|
||||
|
||||
const handleFolderCreate = useCallback(
|
||||
async (name) => {
|
||||
async (name: string) => {
|
||||
if (!token) {
|
||||
setStatusMessage('Log in to create folders.', 'error');
|
||||
return false;
|
||||
@@ -327,7 +413,7 @@ const useFolderTreeActions = ({
|
||||
);
|
||||
|
||||
const handleFolderDelete = useCallback(
|
||||
async (folderId, { showMessage = true, manageLoading = true } = {}) => {
|
||||
async (folderId: FolderKey, { showMessage = true, manageLoading = true }: { showMessage?: boolean; manageLoading?: boolean } = {}) => {
|
||||
if (!token) {
|
||||
if (showMessage) {
|
||||
setStatusMessage('Log in to manage folders.', 'error');
|
||||
@@ -432,9 +518,9 @@ const useFolderTreeActions = ({
|
||||
],
|
||||
);
|
||||
|
||||
const folderClickHandlers = useMemo(
|
||||
const folderClickHandlers: FolderClickHandlers = useMemo(
|
||||
() => ({
|
||||
onToggle: async (folderId) => {
|
||||
onToggle: async (folderId: FolderKey) => {
|
||||
const node = folderNodes.get(folderId);
|
||||
const nextExpanded = !(node?.expanded ?? false);
|
||||
if (nextExpanded) {
|
||||
@@ -465,12 +551,12 @@ const useFolderTreeActions = ({
|
||||
});
|
||||
},
|
||||
onSelect: selectFolder,
|
||||
onDrop: async (event, folderId) => {
|
||||
onDrop: async (event: DragEvent<HTMLElement>, folderId: FolderKey) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.classList.remove('is-drop-target');
|
||||
|
||||
let folderIds = [];
|
||||
let folderIds: FolderId[] = [];
|
||||
try {
|
||||
const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list');
|
||||
if (rawFolderList) {
|
||||
@@ -523,7 +609,7 @@ const useFolderTreeActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
let docIds = [];
|
||||
let docIds: FolderId[] = [];
|
||||
try {
|
||||
const raw = event.dataTransfer.getData('application/x-papercrate-doc-list');
|
||||
if (raw) {
|
||||
@@ -560,7 +646,7 @@ const useFolderTreeActions = ({
|
||||
setDraggedDocumentIds([]);
|
||||
await moveDocumentsToFolder(docIds, folderId);
|
||||
},
|
||||
onDragOver: (event, folderId) => {
|
||||
onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => {
|
||||
const folderDragActive = Boolean(draggedFolderId);
|
||||
if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) {
|
||||
return;
|
||||
@@ -579,7 +665,7 @@ const useFolderTreeActions = ({
|
||||
event.currentTarget.classList.add('is-drop-target');
|
||||
}
|
||||
},
|
||||
onDragLeave: (event) => {
|
||||
onDragLeave: (event: DragEvent<HTMLElement>) => {
|
||||
event.currentTarget.classList.remove('is-drop-target');
|
||||
},
|
||||
}),
|
||||
@@ -1,4 +1,32 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { MutableRefObject, useCallback, useState } from 'react';
|
||||
|
||||
type ApiClient = {
|
||||
get: (path: string) => Promise<{ data: unknown }>
|
||||
post: (path: string, body: unknown) => Promise<{ data: unknown }>
|
||||
patch: (path: string, body: unknown) => Promise<{ data: unknown }>
|
||||
delete: (path: string) => Promise<{ data: unknown }>
|
||||
};
|
||||
|
||||
interface TagManagerInterface {
|
||||
buildPayload: (input: { label?: string; color?: string | null }) => { label: string; color: string | null };
|
||||
}
|
||||
|
||||
interface TagEntry {
|
||||
id?: string | number;
|
||||
label?: string;
|
||||
color?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseTagsOptions {
|
||||
apiClient: ApiClient;
|
||||
notifyApiError: (error: unknown, fallback: string) => void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
tagManager: TagManagerInterface;
|
||||
tenantIdRef: MutableRefObject<string | number | null>;
|
||||
setActiveTagFilters: (updater: (prev: Array<string | number>) => Array<string | number>) => void;
|
||||
mapDocumentCaches?: (mapper: (doc: any) => any) => void;
|
||||
}
|
||||
|
||||
const useTags = ({
|
||||
apiClient,
|
||||
@@ -8,8 +36,8 @@ const useTags = ({
|
||||
tenantIdRef,
|
||||
setActiveTagFilters,
|
||||
mapDocumentCaches,
|
||||
}) => {
|
||||
const [tags, setTags] = useState([]);
|
||||
}: UseTagsOptions) => {
|
||||
const [tags, setTags] = useState<TagEntry[]>([]);
|
||||
|
||||
const refreshTags = useCallback(async () => {
|
||||
const requestTenantId = tenantIdRef.current;
|
||||
@@ -28,12 +56,12 @@ const useTags = ({
|
||||
}, [apiClient, notifyApiError, tenantIdRef]);
|
||||
|
||||
const handleTagUpdate = useCallback(
|
||||
async (tagId, changes) => {
|
||||
async (tagId: string | number | null | undefined, changes: { label?: string; color?: string | null }) => {
|
||||
if (!tagId) {
|
||||
throw new Error('Missing tag identifier.');
|
||||
}
|
||||
|
||||
const payload = {};
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (typeof changes?.label?.trim === 'function') {
|
||||
payload.label = changes.label;
|
||||
}
|
||||
@@ -60,7 +88,7 @@ const useTags = ({
|
||||
);
|
||||
|
||||
const handleTagCreate = useCallback(
|
||||
async ({ label, color } = {}) => {
|
||||
async ({ label, color }: { label?: string; color?: string | null } = {}) => {
|
||||
const payload = tagManager.buildPayload({ label, color });
|
||||
try {
|
||||
await apiClient.post('/tags', payload);
|
||||
@@ -76,7 +104,7 @@ const useTags = ({
|
||||
);
|
||||
|
||||
const handleTagDelete = useCallback(
|
||||
async (tagId) => {
|
||||
async (tagId: string | number | null | undefined) => {
|
||||
if (!tagId) {
|
||||
throw new Error('Missing tag identifier.');
|
||||
}
|
||||
@@ -85,7 +113,7 @@ const useTags = ({
|
||||
await apiClient.delete(`/tags/${tagId}`);
|
||||
setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
|
||||
|
||||
const stripTagFromDoc = (doc) => {
|
||||
const stripTagFromDoc = (doc: any) => {
|
||||
if (!doc || !Array.isArray(doc.tags)) {
|
||||
return doc;
|
||||
}
|
||||
+32
-3
@@ -1,4 +1,33 @@
|
||||
import { useCallback } from 'react';
|
||||
import { MutableRefObject, useCallback } from 'react';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
interface ApiClient {
|
||||
get: (path: string) => Promise<{ data: unknown }>;
|
||||
post: (path: string, body?: unknown) => Promise<{ data: any }>;
|
||||
defaults: { headers: { common: Record<string, string> } };
|
||||
}
|
||||
|
||||
interface TenantOption {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface UseTenantManagerOptions {
|
||||
apiClient: ApiClient;
|
||||
appDispatch: (action: any) => void;
|
||||
currentTenantId: string | number | null;
|
||||
resetWorkspaceState: () => void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
notifyApiError: (error: unknown, message: string) => void;
|
||||
setLoading: (state: boolean) => void;
|
||||
refreshTags: () => Promise<void>;
|
||||
refreshCorrespondents: () => Promise<void>;
|
||||
loadFolder: (folderId: string, options?: { showLoading?: boolean; preserveSearch?: boolean }) => Promise<void>;
|
||||
handleDocumentsViewModeChange: (mode: string) => void;
|
||||
navigate: NavigateFunction;
|
||||
tokenRef?: MutableRefObject<string | null>;
|
||||
tenantIdRef?: MutableRefObject<string | number | null>;
|
||||
}
|
||||
|
||||
const useTenantManager = ({
|
||||
apiClient,
|
||||
@@ -15,9 +44,9 @@ const useTenantManager = ({
|
||||
navigate,
|
||||
tokenRef,
|
||||
tenantIdRef,
|
||||
}) => {
|
||||
}: UseTenantManagerOptions) => {
|
||||
const handleTenantSelect = useCallback(
|
||||
async (tenantOption, { refreshOnly = false } = {}) => {
|
||||
async (tenantOption: TenantOption | null, { refreshOnly = false }: { refreshOnly?: boolean } = {}) => {
|
||||
const requestedTenantId = tenantOption?.id ?? null;
|
||||
if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
|
||||
return;
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const normalizeMessage = (error) => {
|
||||
if (!error) return 'Something went wrong.';
|
||||
if (typeof error?.trim === 'function') {
|
||||
return error;
|
||||
}
|
||||
const { response, message } = error;
|
||||
if (response?.data?.error) return response.data.error;
|
||||
if (response?.data?.message) return response.data.message;
|
||||
return message || 'Something went wrong.';
|
||||
};
|
||||
|
||||
const useApiError = ({
|
||||
logger = console,
|
||||
onReport = noop,
|
||||
} = {}) => {
|
||||
return useCallback(
|
||||
(error, { message, variant = 'error', retry = null } = {}) => {
|
||||
const normalizedMessage = message || normalizeMessage(error);
|
||||
if (logger && typeof logger.error === 'function') {
|
||||
logger.error('[API]', normalizedMessage, error);
|
||||
}
|
||||
onReport({ message: normalizedMessage, variant, retry, error });
|
||||
return normalizedMessage;
|
||||
},
|
||||
[logger, onReport],
|
||||
);
|
||||
};
|
||||
|
||||
export default useApiError;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
type ApiLogger = Pick<typeof console, 'error'>;
|
||||
|
||||
type ApiErrorVariant = 'error' | 'info' | 'success' | 'warning' | string;
|
||||
|
||||
interface ReportPayload {
|
||||
message: string;
|
||||
variant: ApiErrorVariant;
|
||||
retry?: (() => void) | null;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
interface UseApiErrorOptions {
|
||||
logger?: ApiLogger;
|
||||
onReport?: (payload: ReportPayload) => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const normalizeMessage = (error: unknown): string => {
|
||||
if (!error) return 'Something went wrong.';
|
||||
if (typeof (error as { trim?: () => string })?.trim === 'function') {
|
||||
return (error as { trim: () => string }).trim();
|
||||
}
|
||||
const typed = error as { response?: { data?: { error?: string; message?: string } }; message?: string };
|
||||
if (typed.response?.data?.error) return typed.response.data.error;
|
||||
if (typed.response?.data?.message) return typed.response.data.message;
|
||||
return typed.message || 'Something went wrong.';
|
||||
};
|
||||
|
||||
const useApiError = ({
|
||||
logger = console,
|
||||
onReport = noop,
|
||||
}: UseApiErrorOptions = {}) => {
|
||||
return useCallback(
|
||||
(
|
||||
error: unknown,
|
||||
{ message, variant = 'error', retry = null }: { message?: string; variant?: ApiErrorVariant; retry?: (() => void) | null } = {},
|
||||
) => {
|
||||
const normalizedMessage = message || normalizeMessage(error);
|
||||
logger.error('[API]', normalizedMessage, error);
|
||||
onReport({ message: normalizedMessage, variant, retry, error });
|
||||
return normalizedMessage;
|
||||
},
|
||||
[logger, onReport],
|
||||
);
|
||||
};
|
||||
|
||||
export default useApiError;
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createAssetView } from '../asset_manager';
|
||||
|
||||
const clampOrdinalValue = (value, cardinality, defaultOrdinal) => {
|
||||
const raw = Number.isFinite(value) ? value : defaultOrdinal;
|
||||
let next = Math.max(1, Math.floor(raw));
|
||||
if (cardinality && cardinality > 0) {
|
||||
next = Math.min(next, cardinality);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
export const useAssetNavigator = ({
|
||||
document,
|
||||
assetType,
|
||||
ensureAssetUrl,
|
||||
getAsset,
|
||||
prefetch = 2,
|
||||
defaultOrdinal = 1,
|
||||
}) => {
|
||||
const documentId = document?.id || null;
|
||||
|
||||
const asset = useMemo(() => {
|
||||
if (!document || typeof getAsset !== 'function') {
|
||||
return null;
|
||||
}
|
||||
return getAsset(document, assetType);
|
||||
}, [document, assetType, getAsset]);
|
||||
|
||||
const view = useMemo(() => createAssetView(asset), [asset]);
|
||||
const cardinality = view.getCardinality();
|
||||
|
||||
const [ordinal, setOrdinalInternal] = useState(defaultOrdinal);
|
||||
|
||||
useEffect(() => {
|
||||
setOrdinalInternal(defaultOrdinal);
|
||||
}, [documentId, assetType, defaultOrdinal]);
|
||||
|
||||
const setOrdinal = useCallback(
|
||||
(next) => {
|
||||
setOrdinalInternal((prev) => {
|
||||
const target = typeof next === 'function' ? next(prev) : next;
|
||||
return clampOrdinalValue(target, cardinality, defaultOrdinal);
|
||||
});
|
||||
},
|
||||
[cardinality, defaultOrdinal],
|
||||
);
|
||||
|
||||
const goPrev = useCallback(() => setOrdinal((value) => value - 1), [setOrdinal]);
|
||||
const goNext = useCallback(() => setOrdinal((value) => value + 1), [setOrdinal]);
|
||||
|
||||
const objects = view.getObjects();
|
||||
const currentObject = view.getObject(ordinal);
|
||||
const currentUrl = currentObject?.url || view.getPrimaryUrl();
|
||||
const currentMetadata = currentObject?.metadata || view.getPrimaryMetadata() || null;
|
||||
|
||||
const canGoPrev = ordinal > 1;
|
||||
const canGoNext = cardinality ? ordinal < cardinality : true;
|
||||
|
||||
const ordinalsNeedingLoad = useMemo(() => {
|
||||
const missing = [];
|
||||
if (!asset) {
|
||||
return missing;
|
||||
}
|
||||
const maxOrdinal = cardinality && cardinality > 0
|
||||
? Math.min(cardinality, ordinal + Math.max(1, prefetch) - 1)
|
||||
: ordinal + Math.max(1, prefetch) - 1;
|
||||
|
||||
for (let ord = ordinal; ord <= maxOrdinal; ord += 1) {
|
||||
const object = view.getObject(ord);
|
||||
if (!object?.url) {
|
||||
missing.push(ord);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}, [asset, view, ordinal, prefetch, cardinality]);
|
||||
|
||||
const fetchStart = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[0] : null;
|
||||
const fetchEnd = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[ordinalsNeedingLoad.length - 1] : null;
|
||||
const fetchLimit = fetchStart && fetchEnd ? fetchEnd - fetchStart + 1 : null;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentId || !asset || !ensureAssetUrl) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!fetchStart || !fetchLimit) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
ensureAssetUrl(documentId, asset, {
|
||||
start: fetchStart,
|
||||
limit: fetchLimit,
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [documentId, asset, ensureAssetUrl, fetchStart, fetchLimit]);
|
||||
|
||||
return {
|
||||
document,
|
||||
documentId,
|
||||
asset,
|
||||
assetType,
|
||||
ordinal,
|
||||
setOrdinal,
|
||||
goPrev,
|
||||
goNext,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
cardinality,
|
||||
currentObject,
|
||||
currentUrl,
|
||||
currentMetadata,
|
||||
objects,
|
||||
isLoading: loading,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAssetNavigator;
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createAssetView } from '../asset_manager';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AssetObject {
|
||||
ordinal?: number;
|
||||
url?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AssetLike {
|
||||
id?: Identifier;
|
||||
cardinality?: number;
|
||||
url?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
objects?: AssetObject[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type EnsureAssetUrl = (
|
||||
documentId: Identifier,
|
||||
asset: AssetLike,
|
||||
options?: { start?: number; limit?: number; [key: string]: unknown },
|
||||
) => Promise<unknown>;
|
||||
|
||||
type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null | undefined;
|
||||
|
||||
interface AssetViewLike {
|
||||
getCardinality: () => number;
|
||||
getObjects: () => AssetObject[];
|
||||
getObject: (ordinal?: number) => AssetObject | null;
|
||||
getPrimaryUrl: () => string | null;
|
||||
getPrimaryMetadata: () => Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface UseAssetNavigatorOptions {
|
||||
document: DocumentLike | null | undefined;
|
||||
assetType: string;
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
getAsset?: GetAsset;
|
||||
prefetch?: number;
|
||||
defaultOrdinal?: number;
|
||||
}
|
||||
|
||||
type SetOrdinalArg = number | ((prev: number) => number);
|
||||
|
||||
interface AssetNavigatorReturn {
|
||||
document: DocumentLike | null | undefined;
|
||||
documentId: Identifier | null;
|
||||
asset: AssetLike | null;
|
||||
assetType: string;
|
||||
ordinal: number;
|
||||
setOrdinal: (next: SetOrdinalArg) => void;
|
||||
goPrev: () => void;
|
||||
goNext: () => void;
|
||||
canGoPrev: boolean;
|
||||
canGoNext: boolean;
|
||||
cardinality: number;
|
||||
currentObject: AssetObject | null;
|
||||
currentUrl: string | null;
|
||||
currentMetadata: AssetObject['metadata'];
|
||||
objects: AssetObject[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const clampOrdinalValue = (value: number, cardinality: number, defaultOrdinal: number) => {
|
||||
const raw = Number.isFinite(value) ? value : defaultOrdinal;
|
||||
let next = Math.max(1, Math.floor(raw));
|
||||
if (cardinality && cardinality > 0) {
|
||||
next = Math.min(next, cardinality);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
export const useAssetNavigator = ({
|
||||
document,
|
||||
assetType,
|
||||
ensureAssetUrl,
|
||||
getAsset,
|
||||
prefetch = 2,
|
||||
defaultOrdinal = 1,
|
||||
}: UseAssetNavigatorOptions): AssetNavigatorReturn => {
|
||||
const documentId = (document?.id ?? null) as Identifier | null;
|
||||
|
||||
const asset = useMemo<AssetLike | null>(() => {
|
||||
if (!document || typeof getAsset !== 'function') {
|
||||
return null;
|
||||
}
|
||||
return getAsset(document, assetType) || null;
|
||||
}, [document, assetType, getAsset]);
|
||||
|
||||
const view = useMemo<AssetViewLike>(
|
||||
() => createAssetView(asset) as unknown as AssetViewLike,
|
||||
[asset],
|
||||
);
|
||||
const cardinality = view.getCardinality();
|
||||
|
||||
const [ordinal, setOrdinalInternal] = useState(defaultOrdinal);
|
||||
|
||||
useEffect(() => {
|
||||
setOrdinalInternal(defaultOrdinal);
|
||||
}, [documentId, assetType, defaultOrdinal]);
|
||||
|
||||
const setOrdinal = useCallback(
|
||||
(next: SetOrdinalArg) => {
|
||||
setOrdinalInternal((prev) => {
|
||||
const target = typeof next === 'function' ? next(prev) : next;
|
||||
return clampOrdinalValue(target, cardinality, defaultOrdinal);
|
||||
});
|
||||
},
|
||||
[cardinality, defaultOrdinal],
|
||||
);
|
||||
|
||||
const goPrev = useCallback(() => setOrdinal((value) => value - 1), [setOrdinal]);
|
||||
const goNext = useCallback(() => setOrdinal((value) => value + 1), [setOrdinal]);
|
||||
|
||||
const objects = view.getObjects();
|
||||
const currentObject = view.getObject(ordinal);
|
||||
const currentUrl = currentObject?.url ?? view.getPrimaryUrl() ?? null;
|
||||
const currentMetadata = currentObject?.metadata ?? view.getPrimaryMetadata() ?? null;
|
||||
|
||||
const canGoPrev = ordinal > 1;
|
||||
const canGoNext = cardinality ? ordinal < cardinality : true;
|
||||
|
||||
const ordinalsNeedingLoad = useMemo<number[]>(() => {
|
||||
const missing: number[] = [];
|
||||
if (!asset) {
|
||||
return missing;
|
||||
}
|
||||
const safePrefetch = Math.max(1, prefetch);
|
||||
const maxOrdinal = cardinality && cardinality > 0
|
||||
? Math.min(cardinality, ordinal + safePrefetch - 1)
|
||||
: ordinal + safePrefetch - 1;
|
||||
|
||||
for (let ord = ordinal; ord <= maxOrdinal; ord += 1) {
|
||||
const object = view.getObject(ord);
|
||||
if (!object?.url) {
|
||||
missing.push(ord);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}, [asset, view, ordinal, prefetch, cardinality]);
|
||||
|
||||
const fetchStart = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[0] : null;
|
||||
const fetchEnd = ordinalsNeedingLoad.length
|
||||
? ordinalsNeedingLoad[ordinalsNeedingLoad.length - 1]
|
||||
: null;
|
||||
const fetchLimit = fetchStart !== null && fetchEnd !== null ? fetchEnd - fetchStart + 1 : null;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentId || !asset || !ensureAssetUrl) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (fetchStart === null || fetchLimit === null) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
ensureAssetUrl(documentId, asset, {
|
||||
start: fetchStart,
|
||||
limit: fetchLimit,
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [documentId, asset, ensureAssetUrl, fetchStart, fetchLimit]);
|
||||
|
||||
return {
|
||||
document,
|
||||
documentId,
|
||||
asset,
|
||||
assetType,
|
||||
ordinal,
|
||||
setOrdinal,
|
||||
goPrev,
|
||||
goNext,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
cardinality,
|
||||
currentObject,
|
||||
currentUrl,
|
||||
currentMetadata,
|
||||
objects,
|
||||
isLoading: loading,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAssetNavigator;
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Papercrate</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -8,6 +8,11 @@ import { AppStateProvider } from './app/appState';
|
||||
import AppRouter from './app/AppRouter';
|
||||
|
||||
const container = document.getElementById('app');
|
||||
|
||||
if (!container) {
|
||||
throw new Error('App root element #app not found');
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<AppStateProvider>
|
||||
@@ -1,11 +1,40 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
const StatusBanner = ({ status }) => {
|
||||
interface StatusBannerProps {
|
||||
status?: { message: string; variant: string } | null;
|
||||
}
|
||||
|
||||
const StatusBanner: React.FC<StatusBannerProps> = ({ status }) => {
|
||||
if (!status) return null;
|
||||
return <div className={`status-banner ${status.variant}`}>{status.message}</div>;
|
||||
};
|
||||
|
||||
const LoginView = ({
|
||||
interface TenantOption {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface TenantSelectionState {
|
||||
tenants?: TenantOption[];
|
||||
}
|
||||
|
||||
interface LoginViewProps {
|
||||
status?: { message: string; variant: string } | null;
|
||||
tenantSelection?: TenantSelectionState | null;
|
||||
onSelectTenant?: (tenant: TenantOption) => void;
|
||||
onCancelSelection?: () => void;
|
||||
selectingTenantId?: string | number | null;
|
||||
onPasskeyLogin?: (username: string) => void;
|
||||
onSignup?: (username: string) => void;
|
||||
passkeySupported?: boolean;
|
||||
passkeyLoading?: boolean;
|
||||
signupSupported?: boolean;
|
||||
signupLoading?: boolean;
|
||||
magicLoginPending?: boolean;
|
||||
initialUsername?: string;
|
||||
}
|
||||
|
||||
const LoginView: React.FC<LoginViewProps> = ({
|
||||
status,
|
||||
tenantSelection,
|
||||
onSelectTenant,
|
||||
@@ -41,7 +70,7 @@ const LoginView = ({
|
||||
onSignup(username);
|
||||
};
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!passkeySupported || passkeyLoading || magicLoginPending || !username.trim()) {
|
||||
return;
|
||||
@@ -57,7 +86,7 @@ const LoginView = ({
|
||||
<div className="login-card__selection">
|
||||
<p>Select a tenant to finish signing in.</p>
|
||||
<div className="login-card__tenant-list">
|
||||
{tenantSelection.tenants.map((tenant) => (
|
||||
{tenantSelection?.tenants?.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
+49
-4
@@ -1,11 +1,56 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
|
||||
import { DownloadIcon } from '../ui/icons';
|
||||
|
||||
interface DocumentLike {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
content_type?: string;
|
||||
filename?: string;
|
||||
original_name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface PreviewEntry {
|
||||
url?: string;
|
||||
contentType?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
interface ContentTabConfig {
|
||||
id?: string;
|
||||
label?: string;
|
||||
enabled?: boolean;
|
||||
forceDisplay?: boolean;
|
||||
loadContent?: (options?: { signal?: AbortSignal }) => unknown | Promise<unknown>;
|
||||
loadingMessage?: string;
|
||||
emptyMessage?: string;
|
||||
unavailableMessage?: string;
|
||||
errorMessage?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type LayoutMode = 'split' | 'stacked' | (string & {});
|
||||
|
||||
interface DocumentViewerLayoutProps {
|
||||
document?: DocumentLike | null;
|
||||
previewEntry?: PreviewEntry | null;
|
||||
summaryProps?: Record<string, unknown>;
|
||||
metadataPayload?: unknown;
|
||||
contentTabConfig?: ContentTabConfig | null;
|
||||
resetKey?: string | number | null;
|
||||
classNamePrefix?: string;
|
||||
defaultTabId?: string;
|
||||
infoPanelProps?: Record<string, unknown>;
|
||||
previewLoadingMessage?: string;
|
||||
layoutMode?: LayoutMode;
|
||||
}
|
||||
|
||||
const DocumentViewerLayout = ({
|
||||
document,
|
||||
previewEntry,
|
||||
summaryProps,
|
||||
summaryProps = {},
|
||||
metadataPayload,
|
||||
contentTabConfig,
|
||||
resetKey,
|
||||
@@ -14,7 +59,7 @@ const DocumentViewerLayout = ({
|
||||
infoPanelProps = {},
|
||||
previewLoadingMessage = 'Preparing preview…',
|
||||
layoutMode = 'split',
|
||||
}) => {
|
||||
}: DocumentViewerLayoutProps): JSX.Element => {
|
||||
const isStacked = layoutMode === 'stacked';
|
||||
|
||||
const previewContent = useMemo(() => {
|
||||
@@ -103,7 +148,7 @@ const DocumentViewerLayout = ({
|
||||
|
||||
const resolvedDefaultTabId = isStacked ? 'preview' : defaultTabId;
|
||||
const summaryPlacement = 'tabs';
|
||||
const tabsPlacement = isStacked ? 'bottom' : 'top';
|
||||
const tabsPlacement = 'bottom';
|
||||
const summaryLayout = 'compact';
|
||||
|
||||
const detailsPane = (
|
||||
+44
-4
@@ -5,12 +5,13 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
DownloadIcon,
|
||||
CloseIcon,
|
||||
IconZoomInArea,
|
||||
DetailPanelCollapseIcon,
|
||||
IconX,
|
||||
WindowMaximizeIcon,
|
||||
} from '../ui/icons';
|
||||
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
buildCorrespondentOptions,
|
||||
sortCorrespondents,
|
||||
} from '../documents/DocumentSummarySection';
|
||||
import type { DocumentSummarySectionProps } from '../documents/DocumentSummarySection';
|
||||
import { extractDocumentMetadataPayload } from '../documents/documentMetadata';
|
||||
import { createDocumentActionState } from '../documents/documentActions';
|
||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||
@@ -28,6 +30,44 @@ import DocumentViewerLayout from './DocumentViewerLayout';
|
||||
import useViewerLayoutMode from './useViewerLayoutMode';
|
||||
import { usePanelResizeBindings } from '../app/PanelManagerContext';
|
||||
|
||||
interface DocumentLike {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
issued_at?: string | null;
|
||||
correspondents?: Array<{ id?: string | number; name?: string }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AssetLike {
|
||||
id?: string | number;
|
||||
url?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DocumentViewerPanelProps extends DocumentSummarySectionProps {
|
||||
document: DocumentLike | null;
|
||||
previewEntry?: {
|
||||
url?: string;
|
||||
canGoPrev?: boolean;
|
||||
canGoNext?: boolean;
|
||||
goPrev?: () => void;
|
||||
goNext?: () => void;
|
||||
} | null;
|
||||
hydrateDocument?: (doc: DocumentLike | null) => DocumentLike | null;
|
||||
ensureAssetUrl?: (docId: string | number, asset: AssetLike, options?: { start?: number; limit?: number }) => Promise<unknown>;
|
||||
getDocumentAsset?: (doc: DocumentLike | null, type: string) => AssetLike | null | undefined;
|
||||
ensurePreviewData?: (docId: string | number, options?: { signal?: AbortSignal }) => Promise<DocumentLike | null | undefined>;
|
||||
resolveApiPath?: (path: string) => string;
|
||||
notifyApiError?: (error: unknown, fallbackMessage?: string) => void;
|
||||
sidebarToggle?: ReactNode;
|
||||
onClosePanel?: () => void;
|
||||
resolveFolderPath?: (doc: DocumentLike | null) => Array<{ id?: string | number; name?: string }>;
|
||||
variant?: 'viewer' | 'sidebar';
|
||||
onCollapsePanel?: () => void;
|
||||
onMaximizePanel?: () => void;
|
||||
}
|
||||
|
||||
|
||||
export const createDocumentViewerHeaderActions = ({
|
||||
document,
|
||||
@@ -74,7 +114,7 @@ export const createDocumentViewerHeaderActions = ({
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentViewerPanel = ({
|
||||
const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
|
||||
document,
|
||||
previewEntry,
|
||||
hydrateDocument,
|
||||
@@ -239,7 +279,7 @@ const DocumentViewerPanel = ({
|
||||
}
|
||||
}, [hydrateDocument, document?.id]);
|
||||
|
||||
const panelRef = useRef(null);
|
||||
const panelRef = useRef<HTMLDivElement | HTMLFormElement | HTMLElement | null>(null);
|
||||
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
|
||||
|
||||
const {
|
||||
@@ -366,7 +406,7 @@ const DocumentViewerPanel = ({
|
||||
aria-label="Close detail panel"
|
||||
title="Close detail panel"
|
||||
>
|
||||
<DetailPanelCollapseIcon />
|
||||
<IconX />
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user