typescript

This commit is contained in:
2025-11-13 01:07:55 +01:00
parent b812d748ea
commit ada089c05b
147 changed files with 7427 additions and 2534 deletions
+1
View File
@@ -12,5 +12,6 @@ module.exports = {
runtime: 'automatic', runtime: 'automatic',
}, },
], ],
'@babel/preset-typescript',
], ],
}; };
+33
View File
@@ -2,6 +2,10 @@ import js from '@eslint/js';
import pluginReact from 'eslint-plugin-react'; import pluginReact from 'eslint-plugin-react';
import pluginReactHooks from 'eslint-plugin-react-hooks'; import pluginReactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals'; import globals from 'globals';
import tsParser from '@typescript-eslint/parser';
import tsPluginImport from '@typescript-eslint/eslint-plugin';
const tsPlugin = tsPluginImport.default ?? tsPluginImport;
const sharedRules = { const sharedRules = {
...js.configs.recommended.rules, ...js.configs.recommended.rules,
@@ -42,6 +46,7 @@ export default [
plugins: { plugins: {
react: pluginReact, react: pluginReact,
'react-hooks': pluginReactHooks, 'react-hooks': pluginReactHooks,
'@typescript-eslint': tsPlugin,
}, },
settings: { settings: {
react: { react: {
@@ -50,4 +55,32 @@ export default [
}, },
rules: sharedRules, 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: '^_',
},
],
},
},
]; ];
+558 -18
View File
@@ -19,7 +19,10 @@
"@babel/core": "^7.28.5", "@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5", "@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5", "@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@typescript-eslint/eslint-plugin": "^8.46.4",
"@typescript-eslint/parser": "^8.18.1",
"babel-loader": "^10.0.0", "babel-loader": "^10.0.0",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
@@ -29,6 +32,7 @@
"globals": "^16.5.0", "globals": "^16.5.0",
"html-webpack-plugin": "^5.6.4", "html-webpack-plugin": "^5.6.4",
"style-loader": "^4.0.0", "style-loader": "^4.0.0",
"typescript": "^5.7.3",
"webpack": "^5.102.1", "webpack": "^5.102.1",
"webpack-cli": "^6.0.1", "webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2" "webpack-dev-server": "^5.2.2"
@@ -183,18 +187,18 @@
} }
}, },
"node_modules/@babel/helper-create-class-features-plugin": { "node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.28.3", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz",
"integrity": "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==", "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3", "@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-optimise-call-expression": "^7.27.1",
"@babel/helper-replace-supers": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^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" "semver": "^6.3.1"
}, },
"engines": { "engines": {
@@ -295,14 +299,14 @@
} }
}, },
"node_modules/@babel/helper-member-expression-to-functions": { "node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.27.1", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
"integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/traverse": "^7.27.1", "@babel/traverse": "^7.28.5",
"@babel/types": "^7.27.1" "@babel/types": "^7.28.5"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -1541,14 +1545,14 @@
} }
}, },
"node_modules/@babel/plugin-transform-typescript": { "node_modules/@babel/plugin-transform-typescript": {
"version": "7.28.0", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz",
"integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==", "integrity": "sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3", "@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-plugin-utils": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
"@babel/plugin-syntax-typescript": "^7.27.1" "@babel/plugin-syntax-typescript": "^7.27.1"
@@ -1759,9 +1763,9 @@
} }
}, },
"node_modules/@babel/preset-typescript": { "node_modules/@babel/preset-typescript": {
"version": "7.27.1", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz",
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1769,7 +1773,7 @@
"@babel/helper-validator-option": "^7.27.1", "@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^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": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -2303,6 +2307,44 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", "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": "*" "@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": { "node_modules/@webassemblyjs/ast": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -5413,6 +5826,23 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "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": ">= 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": { "node_modules/faye-websocket": {
"version": "0.11.4", "version": "0.11.4",
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
@@ -5845,6 +6285,13 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/handle-thing": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
@@ -7151,6 +7598,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/methods": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -7954,6 +8411,27 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -8303,6 +8781,17 @@
"node": ">= 4" "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": { "node_modules/run-applescript": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
@@ -8316,6 +8805,30 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/safe-array-concat": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -9432,6 +9945,19 @@
"tslib": "2" "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": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -9544,6 +10070,20 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/unbox-primitive": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+5 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "webpack serve --mode development --open", "dev": "webpack serve --mode development --open",
"build": "webpack --mode production", "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" "test:engine": "node --test tests/workspaceEngine.test.js"
}, },
"dependencies": { "dependencies": {
@@ -21,7 +21,10 @@
"@babel/core": "^7.28.5", "@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5", "@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.28.5", "@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@typescript-eslint/eslint-plugin": "^8.46.4",
"@typescript-eslint/parser": "^8.18.1",
"babel-loader": "^10.0.0", "babel-loader": "^10.0.0",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
@@ -31,6 +34,7 @@
"globals": "^16.5.0", "globals": "^16.5.0",
"html-webpack-plugin": "^5.6.4", "html-webpack-plugin": "^5.6.4",
"style-loader": "^4.0.0", "style-loader": "^4.0.0",
"typescript": "^5.7.3",
"webpack": "^5.102.1", "webpack": "^5.102.1",
"webpack-cli": "^6.0.1", "webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2" "webpack-dev-server": "^5.2.2"
@@ -7,7 +7,7 @@ import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace';
import { useDocumentsPreferences } from './useDocumentsPreferences'; import { useDocumentsPreferences } from './useDocumentsPreferences';
import SettingsRoute from './SettingsRoute'; import SettingsRoute from './SettingsRoute';
const AppLayout = () => { const AppLayout: React.FC = () => {
const documentsPreferences = useDocumentsPreferences(); const documentsPreferences = useDocumentsPreferences();
const { const {
appStatus, appStatus,
@@ -1,9 +1,14 @@
import React from 'react'; import React, { ReactNode } from 'react';
import Sidebar from '../sidebar/Sidebar'; import Sidebar from '../sidebar/Sidebar';
import { useSidebarContext } from '../sidebar/SidebarContext'; import { useSidebarContext } from '../sidebar/SidebarContext';
import { usePanelManager } from './PanelManagerContext'; 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 { collapsed } = useSidebarContext();
const { sidebarSuppressed } = usePanelManager(); const { sidebarSuppressed } = usePanelManager();
const sidebarHidden = collapsed || sidebarSuppressed; const sidebarHidden = collapsed || sidebarSuppressed;
@@ -8,7 +8,9 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext'; import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext'; import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
const DocumentsRouteContent = () => { type Breadcrumb = { id?: string | number; name?: string; label?: string; title?: string };
const DocumentsRouteContent: React.FC = () => {
const { const {
sidebarProps, sidebarProps,
documentsTableProps, documentsTableProps,
@@ -64,7 +66,7 @@ const DocumentsRouteContent = () => {
navigate(target); navigate(target);
}, [navigate, parentBreadcrumb]); }, [navigate, parentBreadcrumb]);
const handleHeaderBreadcrumbClick = useCallback((crumb) => { const handleHeaderBreadcrumbClick = useCallback((crumb: Breadcrumb) => {
if (!crumb || !crumb.id) { if (!crumb || !crumb.id) {
return; return;
} }
@@ -121,7 +123,7 @@ const DocumentsRouteContent = () => {
const header = surface.header || null; const header = surface.header || null;
let headerTitle = null; let headerTitle: React.ReactNode = null;
if (header) { if (header) {
const breadcrumbEntries = Array.isArray(header.breadcrumbs) ? header.breadcrumbs.filter(Boolean) : []; const breadcrumbEntries = Array.isArray(header.breadcrumbs) ? header.breadcrumbs.filter(Boolean) : [];
const lastIndex = breadcrumbEntries.length - 1; const lastIndex = breadcrumbEntries.length - 1;
@@ -176,7 +178,7 @@ const DocumentsRouteContent = () => {
); );
}; };
const DocumentsRoute = () => ( const DocumentsRoute: React.FC = () => (
<SidebarProvider> <SidebarProvider>
<PanelManagerProvider> <PanelManagerProvider>
<DocumentsRouteContent /> <DocumentsRouteContent />
@@ -1,6 +1,11 @@
import React from 'react'; 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${active ? ' active' : ''}`}>
<div className="drop-overlay__content"> <div className="drop-overlay__content">
Drop files to upload to <strong>{folderName || 'this location'}</strong> Drop files to upload to <strong>{folderName || 'this location'}</strong>
@@ -11,11 +11,31 @@ import {
} from '../utils/webauthn'; } from '../utils/webauthn';
import { api, useAppDispatch, useAppState } from './appState'; import { api, useAppDispatch, useAppState } from './appState';
const LoginRoute = () => { type StatusVariant = 'info' | 'success' | 'error';
const { status: appStatus, tenantSelection } = useAppState();
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 appDispatch = useAppDispatch();
const location = useLocation(); const location = useLocation();
const [status, setStatus] = useState(null); const [status, setStatus] = useState<StatusMessage | null>(null);
const [selectingTenantId, setSelectingTenantId] = useState(null); const [selectingTenantId, setSelectingTenantId] = useState(null);
const passkeySupported = isWebAuthnAvailable(); const passkeySupported = isWebAuthnAvailable();
const [passkeyLoading, setPasskeyLoading] = useState(false); const [passkeyLoading, setPasskeyLoading] = useState(false);
@@ -1,4 +1,5 @@
import React, { import React, {
ReactNode,
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
@@ -7,48 +8,85 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import type { CSSProperties, PointerEvent as ReactPointerEvent, RefObject } from 'react';
import { useSidebarContext } from '../sidebar/SidebarContext'; 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: { sidebar: {
minRatio: 1 / 6, maxRatio: 1 / 3,
maxRatio: 1 / 4, minPx: 280,
minPx: 240,
}, },
detail: { detail: {
minRatio: 1 / 4, maxRatio: 2 / 3,
maxRatio: 3 / 4,
minPx: 320, minPx: 320,
}, },
}; };
const STORAGE_KEYS = { const STORAGE_KEYS: Record<PanelKey, string> = {
sidebar: 'papercrate_sidebar_width', sidebar: 'papercrate_sidebar_width',
detail: 'papercrate_detail_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 numeric = Number(value);
const limits = PANEL_LIMITS[panel]; const limits = PANEL_LIMITS[panel];
if (!limits) {
return numeric;
}
const viewport = window.innerWidth; 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 ratioMax = Math.round(viewport * limits.maxRatio);
const minLimit = Math.max(0, Math.max(ratioMin, limits.minPx)); const rawMax = Math.max(ratioMax, minLimit);
const rawMax = Math.max(minLimit, ratioMax); const maxAllowed = Math.min(rawMax, viewport - MINIMUM_MAIN_CONTENT_WIDTH);
const maxAllowed = Math.min(rawMax, viewport - 160); const targetMax = Math.min(viewport, Math.max(minLimit, maxAllowed));
const targetMax = Math.max(minLimit, maxAllowed); return Math.min(Math.max(numeric, minLimit), Math.max(0, targetMax));
return Math.min(Math.max(numeric, minLimit), targetMax);
}; };
const readStoredWidth = (panel, fallback) => { const readStoredWidth = (panel: PanelKey, fallback: number): number => {
const raw = window?.localStorage?.getItem(STORAGE_KEYS[panel]); const raw = window.localStorage.getItem(STORAGE_KEYS[panel]);
if (!raw) { if (!raw) {
return fallback; return fallback;
} }
@@ -56,22 +94,26 @@ const readStoredWidth = (panel, fallback) => {
return Number.isFinite(parsed) ? parsed : fallback; return Number.isFinite(parsed) ? parsed : fallback;
}; };
const persistWidth = (panel, value) => { const persistWidth = (panel: PanelKey, value: number): void => {
window?.localStorage?.setItem(STORAGE_KEYS[panel], String(Math.round(value))); 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)) { if (!Number.isFinite(width)) {
return; return;
} }
const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width'; 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 { collapsed, setCollapsed } = useSidebarContext();
const initialSidebarWidth = readStoredWidth('sidebar', 320); const initialSidebarWidth = readStoredWidth('sidebar', DEFAULT_SIDEBAR_WIDTH);
const initialDetailWidth = readStoredWidth('detail', 420); const initialDetailWidth = readStoredWidth('detail', DEFAULT_DETAIL_WIDTH);
const [sidebarWidth, setSidebarWidthState] = useState(() => clampPanelWidth('sidebar', initialSidebarWidth)); const [sidebarWidth, setSidebarWidthState] = useState(() => clampPanelWidth('sidebar', initialSidebarWidth));
const [detailWidth, setDetailWidthState] = useState(() => clampPanelWidth('detail', initialDetailWidth)); const [detailWidth, setDetailWidthState] = useState(() => clampPanelWidth('detail', initialDetailWidth));
@@ -81,6 +123,7 @@ export const PanelManagerProvider = ({ children }) => {
const detailCloseHandlerRef = useRef(null); const detailCloseHandlerRef = useRef(null);
const panelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth }); const panelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
const preferredPanelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
const closeDetailPanel = useCallback(() => { const closeDetailPanel = useCallback(() => {
const handler = detailCloseHandlerRef.current; const handler = detailCloseHandlerRef.current;
@@ -97,15 +140,12 @@ export const PanelManagerProvider = ({ children }) => {
applyPanelWidthToRoot('detail', detailWidth); applyPanelWidthToRoot('detail', detailWidth);
}, [detailWidth]); }, [detailWidth]);
useEffect(() => { const handlePanelLayoutChange = useCallback((
persistWidth('sidebar', sidebarWidth); panel: PanelKey,
}, [sidebarWidth]); action: 'opened' | 'closed' | 'resized',
value?: number,
useEffect(() => { { detailOpen }: { detailOpen?: boolean } = {},
persistWidth('detail', detailWidth); ) => {
}, [detailWidth]);
const logPanelState = useCallback((panel, action, value) => {
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
const sidebarWidth = panelWidthsRef.current.sidebar; const sidebarWidth = panelWidthsRef.current.sidebar;
const detailWidth = panelWidthsRef.current.detail; const detailWidth = panelWidthsRef.current.detail;
@@ -113,6 +153,7 @@ export const PanelManagerProvider = ({ children }) => {
const freeRatio = viewportWidth > 0 ? freeSpace / viewportWidth : 0; const freeRatio = viewportWidth > 0 ? freeSpace / viewportWidth : 0;
const meetsThreshold = freeRatio >= MINIMAL_FREE_RATIO; const meetsThreshold = freeRatio >= MINIMAL_FREE_RATIO;
const effectiveDetailOpen = detailOpen ?? detailPanelOpen;
if (panel === 'detail' && !collapsed) { if (panel === 'detail' && !collapsed) {
if (action === 'opened' || action === 'resized') { 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(); closeDetailPanel();
} }
@@ -138,18 +179,17 @@ export const PanelManagerProvider = ({ children }) => {
minimalFreeRatio: MINIMAL_FREE_RATIO, minimalFreeRatio: MINIMAL_FREE_RATIO,
meetsThreshold, meetsThreshold,
}); });
}, [collapsed, sidebarSuppressed, closeDetailPanel, detailPanelOpen]); }, [collapsed, closeDetailPanel, detailPanelOpen]);
const collapseSidebar = useCallback(() => { const collapseSidebar = useCallback(() => {
if (!collapsed) { if (!collapsed) {
setCollapsed(true); setCollapsed(true);
setSidebarSuppressed(false); handlePanelLayoutChange('sidebar', 'closed');
logPanelState('sidebar', 'closed');
} }
}, [collapsed, setCollapsed, logPanelState]); }, [collapsed, setCollapsed, handlePanelLayoutChange]);
const setPanelWidth = useCallback( const setPanelWidth = useCallback(
(panel, width, commit = true) => { (panel: PanelKey, width: number, { commit = true, log = true }: SetPanelWidthOptions = {}) => {
const clamped = clampPanelWidth(panel, width); const clamped = clampPanelWidth(panel, width);
if (!Number.isFinite(clamped)) { if (!Number.isFinite(clamped)) {
return panelWidthsRef.current[panel]; return panelWidthsRef.current[panel];
@@ -162,34 +202,78 @@ export const PanelManagerProvider = ({ children }) => {
panelWidthsRef.current[panel] = clamped; panelWidthsRef.current[panel] = clamped;
applyPanelWidthToRoot(panel, clamped); applyPanelWidthToRoot(panel, clamped);
if (commit) { if (commit) {
preferredPanelWidthsRef.current[panel] = clamped;
persistWidth(panel, clamped); persistWidth(panel, clamped);
} }
logPanelState(panel, 'resized', clamped); if (log) {
handlePanelLayoutChange(panel, 'resized', clamped);
}
return clamped; return clamped;
}, },
[collapsed, logPanelState, sidebarSuppressed], [handlePanelLayoutChange],
); );
const registerDetailCloseHandler = useCallback((handler = null) => { const resetSidebarPreferredWidth = useCallback(() => {
detailCloseHandlerRef.current = typeof handler === 'function' ? handler : null; 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( const setDetailActive = useCallback(
(isOpen) => { (isOpen) => {
setDetailPanelOpen(Boolean(isOpen)); setDetailPanelOpen(Boolean(isOpen));
logPanelState('detail', isOpen ? 'opened' : 'closed'); handlePanelLayoutChange('detail', isOpen ? 'opened' : 'closed');
}, },
[logPanelState], [handlePanelLayoutChange],
); );
const expandSidebar = useCallback(() => { const expandSidebar = useCallback(() => {
if (collapsed) { if (collapsed) {
setCollapsed(false); setCollapsed(false);
} }
setSidebarSuppressed(false); handlePanelLayoutChange('sidebar', 'opened');
logPanelState('sidebar', 'opened'); }, [collapsed, setCollapsed, handlePanelLayoutChange]);
}, [collapsed, setCollapsed, logPanelState]);
const startPanelResize = useCallback((panel) => { const startPanelResize = useCallback((panel) => {
setResizingPanel(panel); setResizingPanel(panel);
@@ -247,7 +331,10 @@ export const usePanelManager = () => {
return context; 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 { const {
sidebarWidth, sidebarWidth,
detailWidth, detailWidth,
@@ -277,7 +364,7 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true
useEffect(() => () => teardownListeners(), [teardownListeners]); useEffect(() => () => teardownListeners(), [teardownListeners]);
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(event) => { (event: ReactPointerEvent<HTMLDivElement>) => {
if (!enabled || !panelRef?.current) { if (!enabled || !panelRef?.current) {
return; return;
} }
@@ -294,18 +381,18 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true
event.currentTarget?.setPointerCapture?.(pointerId); event.currentTarget?.setPointerCapture?.(pointerId);
let lastWidth = startWidth; let lastWidth = startWidth;
const handlePointerMove = (moveEvent) => { const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) { if (moveEvent.pointerId !== pointerId) {
return; return;
} }
const delta = panel === 'sidebar' const delta = panel === 'sidebar'
? moveEvent.clientX - startX ? moveEvent.clientX - startX
: startX - moveEvent.clientX; : startX - moveEvent.clientX;
lastWidth = setPanelWidth(panel, startWidth + delta, false); lastWidth = setPanelWidth(panel, startWidth + delta, { commit: false });
latestWidthRef.current = lastWidth; latestWidthRef.current = lastWidth;
}; };
const handlePointerUp = (upEvent) => { const handlePointerUp = (upEvent: PointerEvent) => {
if (upEvent.pointerId !== pointerId) { if (upEvent.pointerId !== pointerId) {
return; return;
} }
@@ -6,7 +6,12 @@ import useCapabilitySets from '../settings/useCapabilitySets';
import useCapabilities from '../settings/useCapabilities'; import useCapabilities from '../settings/useCapabilities';
import { api } from './appState'; import { api } from './appState';
const SettingsRoute = ({ open = true, onClose }) => { interface SettingsRouteProps {
open?: boolean;
onClose?: () => void;
}
const SettingsRoute: React.FC<SettingsRouteProps> = ({ open = true, onClose }) => {
const { const {
token, token,
notifyApiError, notifyApiError,
@@ -19,7 +24,7 @@ const SettingsRoute = ({ open = true, onClose }) => {
refreshPasskeys, refreshPasskeys,
registerPasskey, registerPasskey,
revokePasskey, revokePasskey,
} = useAppShell(); } = useAppShell() as Record<string, any>;
const { const {
tokens, 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 { useNavigate } from 'react-router-dom';
import { import {
CloseIcon, CloseIcon,
@@ -12,7 +13,23 @@ import {
} from '../ui/icons'; } from '../ui/icons';
import PanelHeader from '../ui/PanelHeader'; 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: { pending: {
label: 'Queued', label: 'Queued',
tone: 'muted', 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 navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
@@ -1,10 +1,52 @@
import React, { useContext, useEffect, useMemo, useReducer } from 'react'; import React, { useContext, useEffect, useMemo, useReducer } from 'react';
import api from '../lib/api'; 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') ?? ''; const STORED_TOKEN = storage?.getItem('papercrate_token') ?? '';
let STORED_TENANT = null; let STORED_TENANT: Tenant = null;
if (storage) { if (storage) {
try { try {
@@ -21,7 +63,7 @@ if (STORED_TOKEN) {
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`; api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
} }
const initialAppState = { const initialAppState: AppState = {
status: STORED_TOKEN ? 'authenticated' : 'logged-out', status: STORED_TOKEN ? 'authenticated' : 'logged-out',
token: STORED_TOKEN, token: STORED_TOKEN,
error: null, error: null,
@@ -31,10 +73,10 @@ const initialAppState = {
tenants: [], tenants: [],
}; };
const AppStateContext = React.createContext(null); const AppStateContext = React.createContext<AppState | null>(null);
const AppDispatchContext = React.createContext(null); const AppDispatchContext = React.createContext<React.Dispatch<AppAction> | null>(null);
const appStateReducer = (state, action) => { const appStateReducer = (state: AppState, action: AppAction): AppState => {
switch (action.type) { switch (action.type) {
case 'LOGIN_REQUEST': case 'LOGIN_REQUEST':
return { return {
@@ -52,14 +94,14 @@ const appStateReducer = (state, action) => {
token: action.token, token: action.token,
error: null, error: null,
tenantSelection: null, tenantSelection: null,
tenant: action.tenant || null, tenant: action.tenant ?? null,
tenants: state.tenants, tenants: state.tenants,
}; };
case 'LOGIN_FAILURE': case 'LOGIN_FAILURE':
return { return {
status: 'logged-out', status: 'logged-out',
token: '', token: '',
error: action.error || null, error: action.error ?? null,
isRefreshing: false, isRefreshing: false,
tenantSelection: null, tenantSelection: null,
tenant: null, tenant: null,
@@ -89,6 +131,7 @@ const appStateReducer = (state, action) => {
tenants: [], tenants: [],
}; };
case 'LOGOUT_SUCCESS': case 'LOGOUT_SUCCESS':
case 'LOGOUT':
return { return {
status: 'logged-out', status: 'logged-out',
token: '', token: '',
@@ -103,7 +146,7 @@ const appStateReducer = (state, action) => {
case 'BOOTSTRAP_SUCCESS': case 'BOOTSTRAP_SUCCESS':
return { ...state, status: 'ready', error: null }; return { ...state, status: 'ready', error: null };
case 'BOOTSTRAP_FAILURE': case 'BOOTSTRAP_FAILURE':
return { ...state, status: 'authenticated', error: action.error || null }; return { ...state, status: 'authenticated', error: action.error ?? null };
case 'TOKEN_REFRESH_START': case 'TOKEN_REFRESH_START':
return { ...state, isRefreshing: true, error: null }; return { ...state, isRefreshing: true, error: null };
case 'TOKEN_REFRESH_SUCCESS': case 'TOKEN_REFRESH_SUCCESS':
@@ -113,24 +156,14 @@ const appStateReducer = (state, action) => {
isRefreshing: false, isRefreshing: false,
status: state.status === 'logged-out' ? 'authenticated' : state.status, status: state.status === 'logged-out' ? 'authenticated' : state.status,
tenantSelection: null, tenantSelection: null,
tenant: action.tenant || state.tenant || null, tenant: action.tenant ?? state.tenant ?? null,
tenants: state.tenants, tenants: state.tenants,
}; };
case 'TOKEN_REFRESH_FAILURE': case 'TOKEN_REFRESH_FAILURE':
return { return {
status: 'logged-out', status: 'logged-out',
token: '', token: '',
error: action.error || null, error: action.error ?? null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'LOGOUT':
return {
status: 'logged-out',
token: '',
error: null,
isRefreshing: false, isRefreshing: false,
tenantSelection: null, tenantSelection: null,
tenant: 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); const [state, dispatch] = useReducer(appStateReducer, initialAppState);
useEffect(() => { useEffect(() => {
@@ -216,7 +249,7 @@ const AppStateProvider = ({ children }) => {
); );
}; };
const useAppState = () => { const useAppState = (): AppState => {
const context = useContext(AppStateContext); const context = useContext(AppStateContext);
if (!context) { if (!context) {
throw new Error('useAppState must be used within an AppStateProvider.'); throw new Error('useAppState must be used within an AppStateProvider.');
@@ -224,7 +257,7 @@ const useAppState = () => {
return context; return context;
}; };
const useAppDispatch = () => { const useAppDispatch = (): React.Dispatch<AppAction> => {
const context = useContext(AppDispatchContext); const context = useContext(AppDispatchContext);
if (!context) { if (!context) {
throw new Error('useAppDispatch must be used within an AppStateProvider.'); throw new Error('useAppDispatch must be used within an AppStateProvider.');
@@ -1,13 +1,30 @@
import { useCallback, useEffect, useRef, useState } from 'react'; 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 = ({ export const useDetailPanel = ({
documentLookup, documentLookup,
orderedSelectedDocuments, orderedSelectedDocuments,
}) => { }: UseDetailPanelOptions) => {
const [detailPanelOpen, setDetailPanelOpen] = useState(false); const [detailPanelOpen, setDetailPanelOpen] = useState(false);
const [detailPanelDocId, setDetailPanelDocId] = useState(null); const [detailPanelDocId, setDetailPanelDocId] = useState<string | number | null>(null);
const [detailPanelDocument, setDetailPanelDocument] = useState(null); const [detailPanelDocument, setDetailPanelDocument] = useState<DetailDocument | null>(null);
const latestOrderedDocsRef = useRef([]); const latestOrderedDocsRef = useRef<DetailDocument[]>([]);
useEffect(() => { useEffect(() => {
latestOrderedDocsRef.current = orderedSelectedDocuments; latestOrderedDocsRef.current = orderedSelectedDocuments;
@@ -34,7 +51,7 @@ export const useDetailPanel = ({
}, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]); }, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]);
const openDetailPanel = useCallback( const openDetailPanel = useCallback(
({ documentId, document, documentIds, documents } = {}) => { ({ documentId, document, documentIds, documents }: OpenDetailPanelArgs = {}) => {
let targetDoc = document || null; let targetDoc = document || null;
let targetId = documentId ?? document?.id ?? null; let targetId = documentId ?? document?.id ?? null;
@@ -66,7 +83,7 @@ export const useDetailPanel = ({
return; return;
} }
setDetailPanelDocId(targetDoc?.id || targetId || null); setDetailPanelDocId(targetDoc?.id ?? targetId ?? null);
setDetailPanelDocument(targetDoc || null); setDetailPanelDocument(targetDoc || null);
setDetailPanelOpen(Boolean(targetDoc || targetId)); setDetailPanelOpen(Boolean(targetDoc || targetId));
}, },
@@ -1,4 +1,72 @@
import { useCallback, useEffect, useRef, useState } from 'react'; 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 = ({ const useDocumentPreview = ({
routeDocumentId, routeDocumentId,
@@ -14,11 +82,11 @@ const useDocumentPreview = ({
locationSearch, locationSearch,
detailPanelControlRef, detailPanelControlRef,
setActivePreviewId, setActivePreviewId,
}) => { }: UseDocumentPreviewArgs): UseDocumentPreviewResult => {
const [previewEntries, setPreviewEntries] = useState(() => new Map()); const [previewEntries, setPreviewEntries] = useState<Map<DocumentId, PreviewEntry>>(() => new Map());
const [previewDocuments, setPreviewDocuments] = useState(() => new Map()); const [previewDocuments, setPreviewDocuments] = useState<Map<DocumentId, DocumentLike>>(() => new Map());
const previewInflightRef = useRef(new Map()); const previewInflightRef = useRef<Map<DocumentId, Promise<PreviewEntry | null>>>(new Map());
const previewReturnPathRef = useRef(null); const previewReturnPathRef = useRef<string | null>(null);
const resetPreviewState = useCallback(() => { const resetPreviewState = useCallback(() => {
setPreviewEntries(() => new Map()); setPreviewEntries(() => new Map());
@@ -26,7 +94,7 @@ const useDocumentPreview = ({
previewReturnPathRef.current = null; previewReturnPathRef.current = null;
}, []); }, []);
const removePreviewEntries = useCallback((ids) => { const removePreviewEntries = useCallback((ids: DocumentId[]) => {
if (!Array.isArray(ids) || ids.length === 0) { if (!Array.isArray(ids) || ids.length === 0) {
return; return;
} }
@@ -46,7 +114,7 @@ const useDocumentPreview = ({
}); });
}, []); }, []);
const cachePreviewDocument = useCallback((doc) => { const cachePreviewDocument = useCallback((doc: DocumentLike | null | undefined) => {
if (!doc?.id) { if (!doc?.id) {
return; return;
} }
@@ -61,7 +129,7 @@ const useDocumentPreview = ({
}); });
}, []); }, []);
const removeCachedPreviewDocument = useCallback((documentId) => { const removeCachedPreviewDocument = useCallback((documentId?: DocumentId | null) => {
if (!documentId) { if (!documentId) {
return; return;
} }
@@ -76,7 +144,7 @@ const useDocumentPreview = ({
}, []); }, []);
const ensurePreviewUrl = useCallback( const ensurePreviewUrl = useCallback(
async (documentId, { force = false } = {}) => { async (documentId: DocumentId | null, { force = false }: { force?: boolean } = {}): Promise<PreviewEntry | null> => {
if (!documentId) return null; if (!documentId) return null;
const existing = previewEntries.get(documentId) || null; const existing = previewEntries.get(documentId) || null;
@@ -87,19 +155,19 @@ const useDocumentPreview = ({
} }
if (!force && previewInflightRef.current.has(documentId)) { 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 { 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; const downloadPath = docResponse.data?.document?.current_version?.download_path;
if (!downloadPath || !resolveApiPath) { if (!downloadPath || !resolveApiPath) {
throw new Error('Document missing download path'); throw new Error('Document missing download path');
} }
const href = resolveApiPath(downloadPath); const href = resolveApiPath(downloadPath);
const entry = { const entry: PreviewEntry = {
url: href, url: href,
contentType: docResponse.data?.document?.current_version?.version?.content_type || null, contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
filename: docResponse.data?.document?.filename, filename: docResponse.data?.document?.filename,
@@ -122,11 +190,11 @@ const useDocumentPreview = ({
previewInflightRef.current.set(documentId, request); previewInflightRef.current.set(documentId, request);
return request; return request;
}, },
[previewEntries, api, resolveApiPath, notifyApiError, setPreviewEntries], [previewEntries, api, resolveApiPath, notifyApiError],
); );
const ensurePreviewData = useCallback( const ensurePreviewData = useCallback(
async (documentId) => { async (documentId: DocumentId | null): Promise<DocumentLike | null> => {
if (!documentId) return null; if (!documentId) return null;
const findInCache = () => { const findInCache = () => {
@@ -139,18 +207,18 @@ const useDocumentPreview = ({
if (!doc) { if (!doc) {
const { data } = await api.get(`/documents/${documentId}`); const { data } = await api.get(`/documents/${documentId}`);
const hydratedDetail = assetManager.hydrateDetail(data); const hydratedDetail = assetManager.hydrateDetail(data);
const fetched = hydratedDetail?.document || data.document || data; const fetched = hydratedDetail?.document || (data as { document?: DocumentLike })?.document || data;
doc = fetched ? assetManager.hydrateDocument(fetched) : null; doc = fetched ? assetManager.hydrateDocument(fetched) || null : null;
if (!doc) { if (!doc) {
throw new Error('Document metadata unavailable.'); 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) const existsInSearch = Array.isArray(searchResults)
? searchResults.some((item) => item.id === doc.id) ? searchResults.some((item) => item.id === doc?.id)
: false; : false;
if (existsInDocuments || existsInSearch) { if (doc?.id && (existsInDocuments || existsInSearch)) {
removeCachedPreviewDocument(doc.id); removeCachedPreviewDocument(doc.id);
} else { } else {
cachePreviewDocument(doc); cachePreviewDocument(doc);
@@ -180,9 +248,9 @@ const useDocumentPreview = ({
); );
const openDocumentPreview = useCallback( const openDocumentPreview = useCallback(
(documentId, { replace = false } = {}) => { (documentId: DocumentId | null, { replace = false }: { replace?: boolean } = {}) => {
if (!documentId) return; if (!documentId) return;
detailPanelControlRef.current.close(); detailPanelControlRef.current?.close?.();
previewReturnPathRef.current = `${locationPathname}${locationSearch}`; previewReturnPathRef.current = `${locationPathname}${locationSearch}`;
navigate(`/documents/${documentId}`, { replace }); navigate(`/documents/${documentId}`, { replace });
}, },
@@ -190,7 +258,7 @@ const useDocumentPreview = ({
); );
const closeDocumentPreview = useCallback( const closeDocumentPreview = useCallback(
(folderId = null) => { (folderId: FolderId | null = null) => {
const fallbackPath = previewReturnPathRef.current; const fallbackPath = previewReturnPathRef.current;
previewReturnPathRef.current = null; previewReturnPathRef.current = null;
@@ -211,7 +279,7 @@ const useDocumentPreview = ({
return undefined; return undefined;
} }
const handleKeyDown = (event) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
closeDocumentPreview(); closeDocumentPreview();
} }
@@ -248,7 +316,7 @@ const useDocumentPreview = ({
} }
const next = new Map(prev); const next = new Map(prev);
let changed = false; let changed = false;
const prune = (list) => { const prune = (list?: DocumentLike[] | null) => {
if (!Array.isArray(list)) { if (!Array.isArray(list)) {
return; return;
} }
@@ -1,6 +1,30 @@
import { useCallback, useRef, useState } from 'react'; 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 = ({ export const useDocumentSelection = ({
resolveDocumentRowKey, resolveDocumentRowKey,
@@ -9,21 +33,24 @@ export const useDocumentSelection = ({
isFolderRowKey, isFolderRowKey,
getRowId, getRowId,
initialEntries = DEFAULT_INITIAL_ENTRIES, initialEntries = DEFAULT_INITIAL_ENTRIES,
}) => { }: UseDocumentSelectionOptions) => {
const [selectedEntries, setSelectedEntries] = useState(initialEntries); const [selectedEntries, setSelectedEntries] = useState<RowKey[]>(initialEntries);
const [selectionOrder, setSelectionOrder] = useState(initialEntries); const [selectionOrder, setSelectionOrder] = useState<RowKey[]>(initialEntries);
const selectionOrderRef = useRef(initialEntries); const selectionOrderRef = useRef<RowKey[]>(initialEntries);
const selectionAnchorRef = useRef(null); const selectionAnchorRef = useRef<RowKey | null>(null);
const selectionInitializedRef = useRef(false); const selectionInitializedRef = useRef(false);
const [focusedDocumentId, setFocusedDocumentId] = useState(null); const [focusedDocumentId, setFocusedDocumentId] = useState<DocumentId | null>(null);
const [focusedRowKey, setFocusedRowKey] = useState(null); const [focusedRowKey, setFocusedRowKey] = useState<RowKey | null>(null);
const visibleRowKeySetRef = useRef(new Set()); const visibleRowKeySetRef = useRef<Set<RowKey>>(new Set());
const navigableRowKeysRef = useRef([]); const navigableRowKeysRef = useRef<RowKey[]>([]);
const configureSelectionEnvironment = useCallback(({ const configureSelectionEnvironment = useCallback(({
visibleRowKeySet, visibleRowKeySet,
navigableRowKeys, navigableRowKeys,
}: {
visibleRowKeySet?: Set<RowKey>;
navigableRowKeys?: RowKey[];
}) => { }) => {
if (visibleRowKeySet) { if (visibleRowKeySet) {
visibleRowKeySetRef.current = 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 nextSet = new Set(nextSelection);
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id)); const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index); const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index);
@@ -65,13 +92,16 @@ export const useDocumentSelection = ({
}, []); }, []);
const applySelection = useCallback( const applySelection = useCallback(
(rowKeys, { anchor, interactedKeys = [] } = {}) => { (
rowKeys: Array<RowKey | null | undefined>,
{ anchor, interactedKeys = [] }: ApplySelectionOptions = {},
) => {
const visibleRowKeySet = visibleRowKeySetRef.current; const visibleRowKeySet = visibleRowKeySetRef.current;
const unique = []; const unique: RowKey[] = [];
(rowKeys || []).forEach((key) => { (rowKeys || []).forEach((key) => {
if (!key) return; if (!key) return;
let canonicalKey = null; let canonicalKey: RowKey | null | undefined = null;
if (visibleRowKeySet.has(key)) { if (visibleRowKeySet.has(key)) {
canonicalKey = key; canonicalKey = key;
} else if (isDocumentRowKey(key)) { } else if (isDocumentRowKey(key)) {
@@ -99,7 +129,7 @@ export const useDocumentSelection = ({
setSelectedEntries(unique); setSelectedEntries(unique);
updateSelectionOrder(unique, interactedKeys); updateSelectionOrder(unique, interactedKeys);
const nextFocusedDocumentId = (() => { const nextFocusedDocumentId: DocumentId | null = (() => {
if (focusedDocumentId) { if (focusedDocumentId) {
const focusKey = resolveDocumentRowKey(focusedDocumentId); const focusKey = resolveDocumentRowKey(focusedDocumentId);
if (focusKey && unique.includes(focusKey)) { if (focusKey && unique.includes(focusKey)) {
@@ -108,11 +138,11 @@ export const useDocumentSelection = ({
} }
if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) { if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) {
return getRowId(resolvedAnchor) || null; return getRowId(resolvedAnchor) ?? null;
} }
const lastDocKey = [...unique].reverse().find(isDocumentRowKey); const lastDocKey = [...unique].reverse().find((key) => isDocumentRowKey(key)) ?? null;
return lastDocKey ? getRowId(lastDocKey) || null : null; return lastDocKey ? getRowId(lastDocKey) ?? null : null;
})(); })();
setFocusedDocumentId(nextFocusedDocumentId); setFocusedDocumentId(nextFocusedDocumentId);
@@ -144,7 +174,7 @@ export const useDocumentSelection = ({
}, [applySelection]); }, [applySelection]);
const handleEntrySelection = useCallback( const handleEntrySelection = useCallback(
(rowKey, event) => { (rowKey: RowKey | null | undefined, event?: SelectionEventLike) => {
const visibleRowKeySet = visibleRowKeySetRef.current; const visibleRowKeySet = visibleRowKeySetRef.current;
const navigableRowKeys = navigableRowKeysRef.current; const navigableRowKeys = navigableRowKeysRef.current;
if (!rowKey || !visibleRowKeySet.has(rowKey)) { if (!rowKey || !visibleRowKeySet.has(rowKey)) {
@@ -170,8 +200,8 @@ export const useDocumentSelection = ({
anchorKey = rowKey; anchorKey = rowKey;
} }
let nextKeys = []; let nextKeys: RowKey[] = [];
let interactedKeys = []; let interactedKeys: RowKey[] = [];
if (shiftKey && anchorKey) { if (shiftKey && anchorKey) {
const anchorIndex = navigableRowKeys.indexOf(anchorKey); const anchorIndex = navigableRowKeys.indexOf(anchorKey);
@@ -213,7 +243,7 @@ export const useDocumentSelection = ({
); );
const promoteSelectionOrder = useCallback( const promoteSelectionOrder = useCallback(
(docId) => { (docId?: DocumentId | null) => {
if (!docId) return; if (!docId) return;
const rowKey = resolveDocumentRowKey(docId); const rowKey = resolveDocumentRowKey(docId);
if (!rowKey) return; if (!rowKey) return;
@@ -10,7 +10,7 @@ const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field';
const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction'; const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction';
const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants'; const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants';
const readSessionStorage = (key) => { const readSessionStorage = (key: string): string | null => {
try { try {
return window.sessionStorage.getItem(key); return window.sessionStorage.getItem(key);
} catch (error) { } catch (error) {
@@ -19,7 +19,7 @@ const readSessionStorage = (key) => {
} }
}; };
const writeSessionStorage = (key, value) => { const writeSessionStorage = (key: string, value: string): void => {
try { try {
window.sessionStorage.setItem(key, value); window.sessionStorage.setItem(key, value);
} catch (error) { } catch (error) {
@@ -28,7 +28,7 @@ const writeSessionStorage = (key, value) => {
}; };
export const useDocumentsPreferences = () => { export const useDocumentsPreferences = () => {
const [documentsViewMode, setDocumentsViewModeState] = useState(() => { const [documentsViewMode, setDocumentsViewModeState] = useState<'list' | 'grid' | 'desk'>(() => {
const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY); const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY);
if (stored === 'grid' || stored === 'desk') { if (stored === 'grid' || stored === 'desk') {
return stored; return stored;
@@ -36,9 +36,9 @@ export const useDocumentsPreferences = () => {
return 'list'; 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'; const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list';
setDocumentsViewModeState((previous) => { setDocumentsViewModeState((previous) => {
if (next !== previous) { if (next !== previous) {
@@ -75,7 +75,7 @@ export const useDocumentsPreferences = () => {
writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection); writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection);
}, [documentsSortDirection]); }, [documentsSortDirection]);
const handleDocumentsSortFieldChange = useCallback((field) => { const handleDocumentsSortFieldChange = useCallback((field: string) => {
const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD; const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD;
setDocumentsSortField((previous) => (previous === nextField ? previous : nextField)); setDocumentsSortField((previous) => (previous === nextField ? previous : nextField));
}, []); }, []);
@@ -1,10 +1,77 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { import {
TAG_FILTER_UNTAGGED, TAG_FILTER_UNTAGGED,
resolveDocumentRowKey, resolveDocumentRowKey,
isDocumentRowKey, isDocumentRowKey,
} from './appLayoutUtils'; } 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 = ({ const useDocumentsSearch = ({
api, api,
assetManager, assetManager,
@@ -20,14 +87,14 @@ const useDocumentsSearch = ({
notifyApiError, notifyApiError,
setLoading, setLoading,
setSearchIncludeDescendants, setSearchIncludeDescendants,
}) => { }: UseDocumentsSearchArgs): UseDocumentsSearchResult => {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState<string>('');
const [activeTagFilters, setActiveTagFilters] = useState([]); const [activeTagFilters, setActiveTagFilters] = useState<Identifier[]>([]);
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState<Identifier[]>([]);
const [searchResults, setSearchResults] = useState(null); const [searchResults, setSearchResults] = useState<DocumentLike[] | null>(null);
const [searchLoading, setSearchLoading] = useState(false); const [searchLoading, setSearchLoading] = useState<boolean>(false);
const toggleTagFilter = useCallback((tagId) => { const toggleTagFilter = useCallback((tagId: Identifier) => {
if (!tagId) return; if (!tagId) return;
setActiveTagFilters((previous) => { setActiveTagFilters((previous) => {
if (tagId === TAG_FILTER_UNTAGGED) { if (tagId === TAG_FILTER_UNTAGGED) {
@@ -41,7 +108,7 @@ const useDocumentsSearch = ({
}); });
}, []); }, []);
const toggleCorrespondentFilter = useCallback((correspondentId) => { const toggleCorrespondentFilter = useCallback((correspondentId?: Identifier | null) => {
setActiveCorrespondentFilters((previous) => { setActiveCorrespondentFilters((previous) => {
if (!correspondentId) { if (!correspondentId) {
return []; return [];
@@ -68,7 +135,7 @@ const useDocumentsSearch = ({
setSearchIncludeDescendants, setSearchIncludeDescendants,
]); ]);
const handleSearchChange = useCallback((value) => { const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value); setSearchQuery(value);
}, []); }, []);
@@ -98,7 +165,7 @@ const useDocumentsSearch = ({
started = true; started = true;
setLoading(true); setLoading(true);
try { try {
const params = {}; const params: Record<string, unknown> = {};
const trimmedQuery = searchQuery.trim(); const trimmedQuery = searchQuery.trim();
if (trimmedQuery.length) { if (trimmedQuery.length) {
params.query = trimmedQuery; params.query = trimmedQuery;
@@ -131,7 +198,7 @@ const useDocumentsSearch = ({
if (documentsSortDirection) { if (documentsSortDirection) {
params.dir = documentsSortDirection; params.dir = documentsSortDirection;
} }
const { data } = await api.get('/documents', { params }); const { data } = await api.get<unknown[]>('/documents', { params });
if (cancelled) return; if (cancelled) return;
const results = assetManager.hydrateDocuments(data || []); const results = assetManager.hydrateDocuments(data || []);
@@ -148,11 +215,11 @@ const useDocumentsSearch = ({
} }
const resultKeys = results const resultKeys = results
.map((doc) => resolveDocumentRowKey(doc.id)) .map((doc) => resolveDocumentRowKey(doc.id as Identifier))
.filter(Boolean); .filter(Boolean) as Array<Identifier | string>;
let targetKey = null; let targetKey: Identifier | string | null = null;
let nextSelectionKeys = []; let nextSelectionKeys: Array<Identifier | string> = [];
selectionHelpers.setSelectedEntries((previous) => { selectionHelpers.setSelectedEntries((previous) => {
const previousDocKeys = previous.filter(isDocumentRowKey); const previousDocKeys = previous.filter(isDocumentRowKey);
@@ -171,7 +238,7 @@ const useDocumentsSearch = ({
selectionHelpers.setSelectionOrder(nextSelectionKeys); selectionHelpers.setSelectionOrder(nextSelectionKeys);
selectionHelpers.setFocusedDocumentId((previous) => { selectionHelpers.setFocusedDocumentId((previous) => {
if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) { if (previous && resultKeys.includes(resolveDocumentRowKey(previous as Identifier))) {
return previous; return previous;
} }
return null; 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 TagsPanel from '../tags/TagsPanel';
import CorrespondentsPanel from '../correspondents/CorrespondentsPanel'; import CorrespondentsPanel from '../correspondents/CorrespondentsPanel';
import PanelHeader from '../ui/PanelHeader'; import PanelHeader from '../ui/PanelHeader';
@@ -6,21 +7,56 @@ import PanelHeader from '../ui/PanelHeader';
const TAGS_MODAL = 'tags'; const TAGS_MODAL = 'tags';
const CORRESPONDENTS_MODAL = 'correspondents'; 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 = ({ export const useManagementModals = ({
locationPathname, locationPathname,
tags, tags = [],
refreshTags, refreshTags,
onTagCreate, onTagCreate,
onTagUpdate, onTagUpdate,
onTagDelete, onTagDelete,
correspondents, correspondents = [],
refreshCorrespondents, refreshCorrespondents,
onCorrespondentCreate, onCorrespondentCreate,
onCorrespondentUpdate, onCorrespondentUpdate,
onCorrespondentDelete, onCorrespondentDelete,
setStatusMessage, setStatusMessage,
}) => { }: UseManagementModalsArgs): UseManagementModalsResult => {
const [activeModal, setActiveModal] = useState(null); const [activeModal, setActiveModal] = useState<string | null>(null);
const openTagsModal = useCallback(() => setActiveModal(TAGS_MODAL), []); const openTagsModal = useCallback(() => setActiveModal(TAGS_MODAL), []);
const openCorrespondentsModal = useCallback( const openCorrespondentsModal = useCallback(
@@ -37,7 +73,7 @@ export const useManagementModals = ({
if (!activeModal) { if (!activeModal) {
return undefined; return undefined;
} }
const handleKeyDown = (event) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
setActiveModal(null); setActiveModal(null);
@@ -1,17 +1,34 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useDocumentSelection } from './useDocumentSelection'; 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 = ({ export const useWorkspaceSelection = ({
resolveDocumentRowKey, resolveDocumentRowKey,
resolveFolderRowKey, resolveFolderRowKey,
isDocumentRowKey, isDocumentRowKey = () => false,
isFolderRowKey, isFolderRowKey = () => false,
getRowId, getRowId = () => null,
onInspectDocument = identity, onInspectDocument = identity,
onInspectFolder = identity, onInspectFolder = identity,
} = {}) => { }: WorkspaceSelectionOptions = {}) => {
const selection = useDocumentSelection({ const selection = useDocumentSelection({
resolveDocumentRowKey, resolveDocumentRowKey,
resolveFolderRowKey, resolveFolderRowKey,
@@ -58,8 +75,10 @@ export const useWorkspaceSelection = ({
); );
const selectEntry = useCallback( const selectEntry = useCallback(
(entry, event) => { (entry: SelectionEntry | string | null, event?: unknown) => {
const rowKey = entry?.rowKey ?? (typeof entry?.split === 'function' ? entry : null); const rowKey = typeof entry === 'string'
? entry
: entry?.rowKey ?? null;
if (!rowKey) return; if (!rowKey) return;
handleEntrySelection(rowKey, event); handleEntrySelection(rowKey, event);
}, },
@@ -67,7 +86,7 @@ export const useWorkspaceSelection = ({
); );
const inspectDocument = useCallback( const inspectDocument = useCallback(
(documentId) => { (documentId?: string | number | null) => {
if (!documentId) return; if (!documentId) return;
onInspectDocument(documentId); onInspectDocument(documentId);
}, },
@@ -75,7 +94,7 @@ export const useWorkspaceSelection = ({
); );
const inspectFolder = useCallback( const inspectFolder = useCallback(
(folderId) => { (folderId?: string | number | null) => {
if (!folderId) return; if (!folderId) return;
onInspectFolder(folderId); onInspectFolder(folderId);
}, },
@@ -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 { SidebarExpandIcon } from '../ui/icons';
import { createDocumentsSurface } from '../documents/DocumentsPanel'; import { createDocumentsSurface } from '../documents/DocumentsPanel';
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel'; import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
import createDesktopSurface from '../desktop/createDesktopSurface'; import createDesktopSurface from '../desktop/createDesktopSurface';
import { usePanelManager } from './PanelManagerContext'; 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 = ({ export const useWorkspaceSurface = ({
sidebarHidden, sidebarHidden = false,
onExpandSidebar, onExpandSidebar,
documentsTableProps, documentsTableProps,
detailPanelProps, detailPanelProps,
detailPanelOpen, detailPanelOpen = false,
viewMode, viewMode = 'list',
deskWorkspaceProps, deskWorkspaceProps,
previewWorkspaceDocument, previewWorkspaceDocument,
previewWorkspaceEntry, previewWorkspaceEntry,
@@ -24,7 +70,7 @@ export const useWorkspaceSurface = ({
closeDocumentPreview, closeDocumentPreview,
parentBreadcrumb, parentBreadcrumb,
onNavigateParent, onNavigateParent,
}) => { }: UseWorkspaceSurfaceArgs): UseWorkspaceSurfaceResult => {
const { registerDetailCloseHandler, setDetailActive } = usePanelManager(); const { registerDetailCloseHandler, setDetailActive } = usePanelManager();
useEffect(() => { useEffect(() => {
@@ -38,7 +84,7 @@ export const useWorkspaceSurface = ({
return () => setDetailActive(false); return () => setDetailActive(false);
}, [detailPanelOpen, setDetailActive]); }, [detailPanelOpen, setDetailActive]);
const renderSidebarToggle = useCallback(() => { const renderSidebarToggle = useCallback<() => ReactNode>(() => {
if (!sidebarHidden) { if (!sidebarHidden) {
return null; return null;
} }
@@ -55,7 +101,7 @@ export const useWorkspaceSurface = ({
); );
}, [sidebarHidden, onExpandSidebar]); }, [sidebarHidden, onExpandSidebar]);
const documentsSurface = useMemo(() => { const documentsSurface = useMemo<DocumentsSurface>(() => {
if (!documentsTableProps) { if (!documentsTableProps) {
return null; return null;
} }
@@ -78,7 +124,7 @@ export const useWorkspaceSurface = ({
const showPreviewWorkspace = Boolean(previewDocumentId); const showPreviewWorkspace = Boolean(previewDocumentId);
const previewSurface = useMemo(() => { const previewSurface = useMemo<PreviewSurface>(() => {
if (!showPreviewWorkspace) { if (!showPreviewWorkspace) {
return null; return null;
} }
@@ -132,7 +178,7 @@ export const useWorkspaceSurface = ({
detailPanelProps, detailPanelProps,
]); ]);
const workspaceSurface = useMemo(() => { const workspaceSurface = useMemo<DesktopSurface>(() => {
if (viewMode !== 'desk') { if (viewMode !== 'desk') {
return null; return null;
} }
@@ -154,7 +200,7 @@ export const useWorkspaceSurface = ({
detailPanelOpen, detailPanelOpen,
]); ]);
const surface = useMemo(() => { const surface = useMemo<WorkspaceSurface>(() => {
if (showPreviewWorkspace) { if (showPreviewWorkspace) {
return previewSurface; return previewSurface;
} }
@@ -1,12 +1,13 @@
import React from 'react'; 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); const context = React.useContext(AppShellContext);
if (!context) { if (!context) {
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.'); throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
} }
return context; 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) { if (!assetType || !assets) {
return null; return null;
} }
@@ -17,7 +64,7 @@ export const getAssetFromVersion = (currentVersion, assetType) => {
return getAssetFromGroup(currentVersion.assets, assetType); return getAssetFromGroup(currentVersion.assets, assetType);
}; };
const normalizeAssetObjects = (objects) => { const normalizeAssetObjects = (objects?: AssetObject[] | null): AssetObject[] => {
if (!Array.isArray(objects)) { if (!Array.isArray(objects)) {
return []; return [];
} }
@@ -27,31 +74,42 @@ const normalizeAssetObjects = (objects) => {
.sort((a, b) => a.ordinal - b.ordinal); .sort((a, b) => a.ordinal - b.ordinal);
}; };
const mergeAssetObjects = (existingObjects, incomingObjects) => { const mergeAssetObjects = (
const merged = new Map(); existingObjects?: AssetObject[] | null,
incomingObjects?: AssetObject[] | null,
): AssetObject[] => {
const merged = new Map<number, AssetObject>();
normalizeAssetObjects(existingObjects).forEach((entry) => { normalizeAssetObjects(existingObjects).forEach((entry) => {
merged.set(entry.ordinal, { ...entry }); if (typeof entry.ordinal === 'number') {
merged.set(entry.ordinal, { ...entry });
}
}); });
normalizeAssetObjects(incomingObjects).forEach((entry) => { normalizeAssetObjects(incomingObjects).forEach((entry) => {
const current = merged.get(entry.ordinal) || {}; if (typeof entry.ordinal === 'number') {
merged.set(entry.ordinal, { ...current, ...entry }); 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]) .sort((a, b) => a[0] - b[0])
.map(([, value]) => value); .map(([, value]) => value);
}; };
export class AssetView { export class AssetView {
constructor(asset) { asset: AssetLike | null;
private _objectsRef: AssetObject[] | null;
private _sortedObjects: AssetObject[];
constructor(asset?: AssetLike | null) {
this.asset = asset || null; this.asset = asset || null;
this._objectsRef = null; this._objectsRef = null;
this._sortedObjects = []; this._sortedObjects = [];
} }
getCardinality() { getCardinality(): number {
if (!this.asset) { if (!this.asset) {
return 0; return 0;
} }
@@ -69,7 +127,7 @@ export class AssetView {
return this.asset.metadata ? 1 : 0; return this.asset.metadata ? 1 : 0;
} }
getObjects() { getObjects(): AssetObject[] {
if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) { if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) {
return []; return [];
} }
@@ -83,7 +141,7 @@ export class AssetView {
return this._sortedObjects; return this._sortedObjects;
} }
getObject(ordinal = 1) { getObject(ordinal = 1): AssetObject | null {
const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal); const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal);
if (fromObjects) { if (fromObjects) {
return fromObjects; return fromObjects;
@@ -103,30 +161,40 @@ export class AssetView {
return null; return null;
} }
getPrimaryObject() { getPrimaryObject(): AssetObject | null {
return this.getObject(1); return this.getObject(1);
} }
getPrimaryMetadata() { getPrimaryMetadata(): Record<string, unknown> | null {
return this.getPrimaryObject()?.metadata || null; return this.getPrimaryObject()?.metadata || null;
} }
getPrimaryUrl() { getPrimaryUrl(): string | null {
return this.getPrimaryObject()?.url || null; return this.getPrimaryObject()?.url || null;
} }
hasObject(ordinal) { hasObject(ordinal: number): boolean {
return Boolean(this.getObject(ordinal)); 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 = ( export const resolveDocumentAssetUrl = (
doc, doc: DocumentLike | null | undefined,
type, type: string,
{ ensureAssetUrl, getAsset, ensureOptions, objectOrdinal = 1 } = {}, {
) => { ensureAssetUrl,
getAsset,
ensureOptions,
objectOrdinal = 1,
}: {
ensureAssetUrl?: EnsureAssetUrl;
getAsset?: GetAsset;
ensureOptions?: { start?: number; limit?: number; [key: string]: unknown };
objectOrdinal?: number;
} = {},
): string | null => {
if (!doc || !type) { if (!doc || !type) {
return null; return null;
} }
@@ -148,7 +216,7 @@ export const resolveDocumentAssetUrl = (
} }
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') { if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
const force = Boolean(url && expiresAt && expiresAt <= now); const force = Boolean(url && expiresAt && expiresAt <= now);
const options = { const options: { force: boolean; start?: number | null; limit?: number | null; [key: string]: unknown } = {
force, force,
start: objectOrdinal, start: objectOrdinal,
limit: 1, limit: 1,
@@ -166,24 +234,29 @@ export const resolveDocumentAssetUrl = (
}; };
class AssetManager { 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.api = api;
this.assetPresignTtlMs = assetPresignTtlMs; this.assetPresignTtlMs = assetPresignTtlMs;
this.assetCache = new Map(); this.assetCache = new Map();
this.assetInflight = new Map(); this.assetInflight = new Map();
} }
setApi(api) { setApi(api: AxiosInstance | null) {
this.api = api; this.api = api;
} }
rememberAsset(entry) { rememberAsset(entry: AssetLike | null | undefined) {
if (entry?.id) { if (entry?.id) {
this.assetCache.set(entry.id, entry); this.assetCache.set(entry.id, entry);
} }
} }
hydrateAsset(asset) { hydrateAsset(asset: AssetLike | null | undefined): AssetLike | null | undefined {
if (!asset || !asset.id) { if (!asset || !asset.id) {
return asset; return asset;
} }
@@ -213,7 +286,7 @@ class AssetManager {
return merged; return merged;
} }
hydrateDocument(document) { hydrateDocument(document: DocumentLike | null | undefined): DocumentLike | null | undefined {
if (!document) { if (!document) {
return document; return document;
} }
@@ -252,18 +325,18 @@ class AssetManager {
return document; return document;
} }
const nextCurrentVersion = { ...currentVersion, assets: nextAssets }; const nextCurrentVersion: DocumentVersionLike = { ...currentVersion, assets: nextAssets };
return { ...document, current_version: nextCurrentVersion }; return { ...document, current_version: nextCurrentVersion };
} }
hydrateDocuments(documents) { hydrateDocuments(documents: DocumentLike[] | null | undefined) {
if (!Array.isArray(documents)) { if (!Array.isArray(documents)) {
return documents; return documents;
} }
return documents.map((doc) => this.hydrateDocument(doc)); return documents.map((doc) => this.hydrateDocument(doc));
} }
hydrateDetail(detail) { hydrateDetail(detail: { document?: DocumentLike; assets?: AssetLike[] } | null | undefined) {
if (!detail) { if (!detail) {
return detail; return detail;
} }
@@ -292,7 +365,10 @@ class AssetManager {
return changed ? next : detail; return changed ? next : detail;
} }
hydrateFolderContents(contents) { hydrateFolderContents(contents: {
documents?: DocumentLike[];
document?: DocumentLike;
} | null | undefined) {
if (!contents) { if (!contents) {
return contents; return contents;
} }
@@ -306,7 +382,11 @@ class AssetManager {
return next; 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) { if (!documentId || !asset?.id) {
return Promise.resolve(asset || null); return Promise.resolve(asset || null);
} }
@@ -361,7 +441,7 @@ class AssetManager {
return Promise.reject(new Error('AssetManager API client is not configured.')); return Promise.reject(new Error('AssetManager API client is not configured.'));
} }
const params = {}; const params: Record<string, number> = {};
if (Number.isInteger(start) && start > 0) { if (Number.isInteger(start) && start > 0) {
params.start = start; params.start = start;
} }
@@ -371,7 +451,7 @@ class AssetManager {
const requestConfig = Object.keys(params).length ? { params } : undefined; const requestConfig = Object.keys(params).length ? { params } : undefined;
const request = this.api const request: Promise<AssetLike | null> = this.api
.get(`/assets/${asset.id}`, requestConfig) .get(`/assets/${asset.id}`, requestConfig)
.then(({ data }) => { .then(({ data }) => {
const incomingObjects = Array.isArray(data.objects) ? data.objects : []; const incomingObjects = Array.isArray(data.objects) ? data.objects : [];
+72
View File
@@ -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

@@ -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({ function CorrespondentsPanel({
correspondents = [], correspondents = [],
@@ -7,15 +29,15 @@ function CorrespondentsPanel({
onUpdate, onUpdate,
onDelete, onDelete,
onNotify, onNotify,
}) { }: CorrespondentsPanelProps) {
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState<string | number | null>(null);
const [draftName, setDraftName] = useState(''); const [draftName, setDraftName] = useState('');
const [createName, setCreateName] = useState(''); const [createName, setCreateName] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [creating, setCreating] = 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); setEditingId(correspondent.id);
setDraftName(correspondent.name); setDraftName(correspondent.name);
}, []); }, []);
@@ -46,7 +68,7 @@ function CorrespondentsPanel({
}, [editingId, draftName, onUpdate, cancelEdit, onNotify]); }, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
const handleDelete = useCallback( const handleDelete = useCallback(
async (correspondent) => { async (correspondent: CorrespondentEntry) => {
if (!correspondent?.id) return; if (!correspondent?.id) return;
setDeletingId(correspondent.id); setDeletingId(correspondent.id);
try { try {
@@ -65,7 +87,7 @@ function CorrespondentsPanel({
); );
const handleCreate = useCallback( const handleCreate = useCallback(
async (event) => { async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
const trimmed = createName.trim(); const trimmed = createName.trim();
if (!trimmed) { if (!trimmed) {
@@ -87,7 +109,7 @@ function CorrespondentsPanel({
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(event) => { (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
handleSave(); handleSave();
@@ -99,7 +121,7 @@ function CorrespondentsPanel({
[handleSave, cancelEdit], [handleSave, cancelEdit],
); );
const renderUsage = useCallback((usage) => { const renderUsage = useCallback((usage?: CorrespondentUsage) => {
if (!usage) { if (!usage) {
return '0'; return '0';
} }
@@ -4,7 +4,46 @@ import { resolveCorrespondents } from '../documents/correspondents';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { preventAll } from './events'; 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, doc,
style, style,
shouldLoad, shouldLoad,
@@ -52,12 +91,29 @@ const DesktopDocumentCard = ({
data-doc-id={doc.id} data-doc-id={doc.id}
data-tag-ids={dataTagIds} data-tag-ids={dataTagIds}
aria-hidden={ariaHidden} aria-hidden={ariaHidden}
ref={registerNode} ref={registerNode ?? undefined}
{...cardPointerHandlers} {...cardPointerHandlers}
onDragEnter={(event) => onTagDragEnter(event, doc.id)} onDragEnter={(event) => {
onDragOver={(event) => onTagDragOver(event, doc.id)} if (doc?.id == null) {
onDragLeave={(event) => onTagDragLeave(event, doc.id)} return;
onDrop={(event) => onTagDrop(event, doc)} }
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) => { onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
preventAll(event); preventAll(event);
@@ -107,11 +163,11 @@ const DesktopDocumentCard = ({
draggable draggable
data-desk-tag-chip="true" data-desk-tag-chip="true"
onPointerDownCapture={(event) => { onPointerDownCapture={(event) => {
onDocTagPointerDown(event, doc, tag); onDocTagPointerDown?.(event, doc, tag);
}} }}
onDragStart={(event) => onDocTagDragStart(event, doc, tag)} onDragStart={(event) => onDocTagDragStart?.(event, doc, tag)}
onDrag={onDocTagDrag} onDrag={onDocTagDrag}
onDragEnd={(event) => onDocTagDragEnd(event)} onDragEnd={(event) => onDocTagDragEnd?.(event)}
> >
<span className="tag-chip__label">{tag.label}</span> <span className="tag-chip__label">{tag.label}</span>
</span> </span>
@@ -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 { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
import { useAssetNavigator } from '../hooks/useAssetNavigator'; import { useAssetNavigator } from '../hooks/useAssetNavigator';
import { preventAll } from './events'; 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 = ({ const DesktopPreviewCard = ({
doc, doc,
title, title,
@@ -11,7 +59,7 @@ const DesktopPreviewCard = ({
prefetch = 3, prefetch = 3,
onNavigatorSnapshot, onNavigatorSnapshot,
shouldLoad = true, shouldLoad = true,
}) => { }: DesktopPreviewCardProps): JSX.Element => {
const navigator = useAssetNavigator({ const navigator = useAssetNavigator({
document: doc, document: doc,
assetType: 'preview', assetType: 'preview',
@@ -23,8 +71,8 @@ const DesktopPreviewCard = ({
const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator; const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator;
const docId = doc?.id ?? null; const docId = doc?.id ?? null;
const metadataWidth = Number(currentMetadata?.width); const metadataWidth = Number((currentMetadata as { width?: number } | null)?.width);
const metadataHeight = Number(currentMetadata?.height); const metadataHeight = Number((currentMetadata as { height?: number } | null)?.height);
useEffect(() => { useEffect(() => {
if (!onNavigatorSnapshot || !docId) { if (!onNavigatorSnapshot || !docId) {
@@ -143,4 +191,3 @@ const DesktopPreviewCard = ({
}; };
export default DesktopPreviewCard; export default DesktopPreviewCard;
@@ -28,9 +28,168 @@ import '../styles/workspace/workspace-layout.css';
import '../styles/workspace/workspace-items.css'; import '../styles/workspace/workspace-items.css';
import '../styles/workspace/workspace-cards.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_DRAG = false;
const DEBUG_FOCUS = false; const DEBUG_FOCUS = false;
const DesktopWorkspace = ({ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
documents = [], documents = [],
searchResults = null, searchResults = null,
onDocumentOpen, onDocumentOpen,
@@ -50,28 +209,35 @@ const DesktopWorkspace = ({
tenantId = null, tenantId = null,
viewId = 'default', 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 allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:'));
const containerRef = useRef(null); const containerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef(new Map()); const itemRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
const dragTransformsRef = useRef(new Map()); const dragTransformsRef = useRef<Map<string, DragTransformOverride>>(new Map());
const [overlayDocId, setOverlayDocId] = useState(null); const [overlayDocId, setOverlayDocId] = useState<string | null>(null);
const [overlayOriginRect, setOverlayOriginRect] = useState(null); const [overlayOriginRect, setOverlayOriginRect] = useState<DOMRect | null>(null);
const [overlayOriginTransform, setOverlayOriginTransform] = useState(null); const [overlayOriginTransform, setOverlayOriginTransform] = useState<OverlayOriginTransform | null>(
const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map()); null,
);
const [previewSnapshots, setPreviewSnapshots] = useState<Map<string, NavigatorSnapshot>>(
() => new Map(),
);
const [docSizeVersion, setDocSizeVersion] = useState(0); const [docSizeVersion, setDocSizeVersion] = useState(0);
const docSizeMapRef = useRef(new Map()); const docSizeMapRef = useRef<Map<string, DocumentSizeInfo>>(new Map());
const ensureDocumentSize = useCallback((doc) => { const ensureDocumentSize = useCallback((doc: DeskDocument | null | undefined): DocumentSizeInfo | null => {
if (!doc?.id) { if (!doc?.id) {
return null; return null;
} }
return docSizeMapRef.current.get(String(doc.id)) || null; return docSizeMapRef.current.get(String(doc.id)) || null;
}, []); }, []);
const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl); const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl);
const documentLookup = useMemo(() => { const documentLookup = useMemo<Map<string, DeskDocument>>(() => {
const map = new Map(); const map = new Map<string, DeskDocument>();
items.forEach((doc) => { items.forEach((doc) => {
const key = doc?.id != null ? String(doc.id) : null; const key = doc?.id != null ? String(doc.id) : null;
if (key) { if (key) {
@@ -81,7 +247,7 @@ const DesktopWorkspace = ({
return map; return map;
}, [items]); }, [items]);
const engineRef = useRef(null); const engineRef = useRef<WorkspaceEngine | null>(null);
if (!engineRef.current) { if (!engineRef.current) {
engineRef.current = new WorkspaceEngine({ engineRef.current = new WorkspaceEngine({
allowLayoutPersistence, allowLayoutPersistence,
@@ -89,7 +255,7 @@ const DesktopWorkspace = ({
viewId, viewId,
}); });
} }
const engine = engineRef.current; const engine = engineRef.current as WorkspaceEngine;
useEffect(() => { useEffect(() => {
engine.updateConfig({ allowLayoutPersistence, tenantId, viewId }); engine.updateConfig({ allowLayoutPersistence, tenantId, viewId });
@@ -115,7 +281,7 @@ const DesktopWorkspace = ({
engine.setEnsureDocumentSize(ensureDocumentSize); engine.setEnsureDocumentSize(ensureDocumentSize);
}, [engine, ensureDocumentSize]); }, [engine, ensureDocumentSize]);
const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore); const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore) as WorkspaceSnapshotState;
const { const {
layout: layoutSnapshot, layout: layoutSnapshot,
canvasSize, canvasSize,
@@ -141,10 +307,10 @@ const DesktopWorkspace = ({
engine.setItemRefs(itemRefs); engine.setItemRefs(itemRefs);
}, [engine, itemRefs]); }, [engine, itemRefs]);
const layoutRef = useRef(layoutSnapshot); const layoutRef = useRef<Map<string, LayoutEntry>>(layoutSnapshot);
layoutRef.current = engine.layout; layoutRef.current = engine.layout as Map<string, LayoutEntry>;
const bringToFront = useCallback((docId) => { const bringToFront = useCallback((docId: Identifier | null | undefined) => {
engine.bringToFront(docId); engine.bringToFront(docId);
}, [engine]); }, [engine]);
@@ -156,11 +322,11 @@ const DesktopWorkspace = ({
engine.recalcVisibleDocIds(); engine.recalcVisibleDocIds();
}, [engine]); }, [engine]);
const setDraggingId = useCallback((value) => { const setDraggingId = useCallback((value: string | number | null) => {
engine.setDraggingId(value); engine.setDraggingId(value);
}, [engine]); }, [engine]);
const applySnapshotDimensions = useCallback((docKey, snapshot) => { const applySnapshotDimensions = useCallback((docKey: string, snapshot: NavigatorSnapshot | null) => {
const width = Number(snapshot?.width); const width = Number(snapshot?.width);
const height = Number(snapshot?.height); const height = Number(snapshot?.height);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
@@ -180,7 +346,7 @@ const DesktopWorkspace = ({
setDocSizeVersion((value) => value + 1); setDocSizeVersion((value) => value + 1);
}, []); }, []);
const handleNavigatorSnapshot = useCallback( const handleNavigatorSnapshot = useCallback(
(docId, snapshot) => { (docId: Identifier | null, snapshot: NavigatorSnapshot | null) => {
const docKey = docId != null ? String(docId) : null; const docKey = docId != null ? String(docId) : null;
if (!docKey) { if (!docKey) {
return; return;
@@ -222,11 +388,11 @@ const DesktopWorkspace = ({
}, },
[applySnapshotDimensions], [applySnapshotDimensions],
); );
const activeTagSet = useMemo(() => { const activeTagSet = useMemo<Set<string>>(() => {
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) { if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
return new Set(); return new Set();
} }
const set = new Set(); const set = new Set<string>();
activeTagIds.forEach((id) => { activeTagIds.forEach((id) => {
if (id != null) { if (id != null) {
set.add(String(id)); set.add(String(id));
@@ -251,20 +417,20 @@ const DesktopWorkspace = ({
commitSize(); commitSize();
if (!('ResizeObserver' in window)) { if (typeof window.ResizeObserver === 'undefined') {
window.addEventListener('resize', commitSize); window.addEventListener('resize', commitSize);
return () => { return () => {
window.removeEventListener('resize', commitSize); window.removeEventListener('resize', commitSize);
}; };
} }
const observer = new ResizeObserver(commitSize); const observer = new window.ResizeObserver(commitSize);
observer.observe(container); observer.observe(container);
return () => observer.disconnect(); return () => observer.disconnect();
}, [engine]); }, [engine]);
const resolvePreviewDimensions = useCallback( const resolvePreviewDimensions = useCallback(
(doc) => { (doc: DeskDocument | null | undefined): PreviewMetadataEntry | null => {
if (!doc?.id) { if (!doc?.id) {
return null; return null;
} }
@@ -299,7 +465,7 @@ const DesktopWorkspace = ({
const focusTarget = () => { const focusTarget = () => {
try { try {
canvas.focus({ preventScroll: true }); canvas.focus({ preventScroll: true });
} catch (error) { } catch (error: unknown) {
if (DEBUG_FOCUS) { if (DEBUG_FOCUS) {
void error; void error;
} }
@@ -386,7 +552,7 @@ const DesktopWorkspace = ({
} }
}, [items, previewMetadata]); }, [items, previewMetadata]);
const overlayDisplay = useMemo(() => { const overlayDisplay = useMemo<OverlayDisplay | null>(() => {
if (!overlayDocId) { if (!overlayDocId) {
return null; return null;
} }
@@ -395,7 +561,7 @@ const DesktopWorkspace = ({
return null; return null;
} }
const doc = documentLookup.get(overlayDocId); const doc = documentLookup.get(overlayDocId);
const alt = snapshot.alt || doc?.title; const alt = snapshot.alt || (doc?.title as string | undefined);
return { return {
url: snapshot.url, url: snapshot.url,
alt, alt,
@@ -421,7 +587,7 @@ const DesktopWorkspace = ({
}, [overlayDocId, documentLookup]); }, [overlayDocId, documentLookup]);
const resolveBaseMetrics = useCallback( const resolveBaseMetrics = useCallback(
(doc, cardWidth, cardHeight) => { (doc: DeskDocument | null | undefined, cardWidth: number, cardHeight: number) => {
const previewDims = doc ? resolvePreviewDimensions(doc) : null; const previewDims = doc ? resolvePreviewDimensions(doc) : null;
if (previewDims?.width && previewDims?.height) { if (previewDims?.width && previewDims?.height) {
const baseWidth = Math.max(previewDims.width, cardWidth); const baseWidth = Math.max(previewDims.width, cardWidth);
@@ -452,7 +618,7 @@ const DesktopWorkspace = ({
}, [draggingId, items, setDraggingId]); }, [draggingId, items, setDraggingId]);
const openOverlayForDoc = useCallback( const openOverlayForDoc = useCallback(
(docId, originInfo = null) => { (docId: Identifier | null | undefined, originInfo: OverlayOriginTransform | null = null) => {
if (!docId) { if (!docId) {
return; return;
} }
@@ -462,7 +628,9 @@ const DesktopWorkspace = ({
return; return;
} }
const container = itemRefs.current.get(docKey); 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) { if (!container || !imageNode) {
return; return;
} }
@@ -514,7 +682,7 @@ const DesktopWorkspace = ({
], ],
); );
const dragSettings = useMemo( const dragSettings = useMemo<DragSettings>(
() => ({ () => ({
canvasPadding: DESK_CANVAS_PADDING, canvasPadding: DESK_CANVAS_PADDING,
defaultCanvasWidth: DESK_DEFAULT_CANVAS_WIDTH, defaultCanvasWidth: DESK_DEFAULT_CANVAS_WIDTH,
@@ -524,7 +692,7 @@ const DesktopWorkspace = ({
[], [],
); );
const viewProps = useMemo( const viewProps = useMemo<DesktopWorkspaceViewProps>(
() => ({ () => ({
engine, engine,
items, items,
@@ -633,7 +801,7 @@ const DesktopWorkspace = ({
return <DesktopWorkspaceView {...viewProps} />; return <DesktopWorkspaceView {...viewProps} />;
}; };
const DesktopWorkspaceView = ({ function DesktopWorkspaceView({
engine, engine,
items, items,
containerRef, containerRef,
@@ -684,7 +852,7 @@ const DesktopWorkspaceView = ({
onInspectDocument, onInspectDocument,
markLayoutDirty, markLayoutDirty,
dragTransformsRef, dragTransformsRef,
}) => { }: DesktopWorkspaceViewProps) {
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
useDocumentDrag({ useDocumentDrag({
engine, engine,
@@ -705,7 +873,12 @@ const DesktopWorkspaceView = ({
onDocumentStackSelect, onDocumentStackSelect,
selectedDocumentIds, selectedDocumentIds,
markLayoutDirty, markLayoutDirty,
}); }) as {
handlePointerDown: React.PointerEventHandler<HTMLElement>;
handlePointerMove: React.PointerEventHandler<HTMLElement>;
handlePointerUp: React.PointerEventHandler<HTMLElement>;
handlePointerCancel: React.PointerEventHandler<HTMLElement>;
};
const { getCardPointerHandlers, handleShellKeyDown, focusShell } = useDeskPointer({ const { getCardPointerHandlers, handleShellKeyDown, focusShell } = useDeskPointer({
containerRef, containerRef,
@@ -725,7 +898,11 @@ const DesktopWorkspaceView = ({
detailPanelOpen, detailPanelOpen,
onCloseDetailPanel, onCloseDetailPanel,
openOverlayForDoc, openOverlayForDoc,
}); }) as {
getCardPointerHandlers: (doc: DeskDocument) => React.HTMLAttributes<HTMLDivElement>;
handleShellKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
focusShell: () => void;
};
useEffect(() => { useEffect(() => {
focusShell(); focusShell();
@@ -745,7 +922,7 @@ const DesktopWorkspaceView = ({
const allSizesReady = items.every((doc) => ensureDocumentSize(doc)); const allSizesReady = items.every((doc) => Boolean(ensureDocumentSize(doc)));
return ( return (
<> <>
@@ -780,7 +957,7 @@ const DesktopWorkspaceView = ({
<p>No documents to show here yet. Drop files to make this space come alive.</p> <p>No documents to show here yet. Drop files to make this space come alive.</p>
</div> </div>
) : ( ) : (
items.map((doc) => { items.map((doc, index) => {
const sizeInfo = ensureDocumentSize(doc); const sizeInfo = ensureDocumentSize(doc);
if (!sizeInfo) { if (!sizeInfo) {
return null; return null;
@@ -797,10 +974,12 @@ const DesktopWorkspaceView = ({
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
return null; return null;
} }
const resolvedCenterX = centerX as number;
const resolvedCenterY = centerY as number;
const rotation = dragOverride?.rotation ?? layout?.rotation ?? 0; const rotation = dragOverride?.rotation ?? layout?.rotation ?? 0;
const scale = dragOverride?.scale ?? 1; const scale = dragOverride?.scale ?? 1;
const originX = centerX - cardWidth / 2; const originX = resolvedCenterX - cardWidth / 2;
const originY = centerY - cardHeight / 2; const originY = resolvedCenterY - cardHeight / 2;
const transform = formatTransform( const transform = formatTransform(
Math.round(originX), Math.round(originX),
Math.round(originY), Math.round(originY),
@@ -816,16 +995,19 @@ const DesktopWorkspaceView = ({
const shouldLoad = docKey ? visibleDocIds.has(docKey) : false; const shouldLoad = docKey ? visibleDocIds.has(docKey) : false;
const dragging = docKey ? draggingId === docKey : false; const dragging = docKey ? draggingId === docKey : false;
const docTagKeys = Array.isArray(doc?.tags) 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 = const matchesFilter =
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key)); activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
const dropActive = docKey ? tagDropTargetId === docKey : false; const dropActive = docKey ? tagDropTargetId === docKey : false;
const dropPending = docKey ? pendingTagDocId === 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 docTagTokens = docTagKeys.join(' ');
const cardPointerHandlers = getCardPointerHandlers(doc); const cardPointerHandlers = getCardPointerHandlers(doc) as React.HTMLAttributes<HTMLDivElement>;
const registerNode = (node) => { const registerNode = (node: HTMLDivElement | null) => {
if (!docKey) { if (!docKey) {
return; return;
} }
@@ -838,7 +1020,7 @@ const DesktopWorkspaceView = ({
return ( return (
<DesktopDocumentCard <DesktopDocumentCard
key={doc.id} key={docKey ?? `desk-doc-${index}`}
doc={doc} doc={doc}
style={style} style={style}
shouldLoad={shouldLoad} shouldLoad={shouldLoad}
@@ -878,6 +1060,6 @@ const DesktopWorkspaceView = ({
/> />
</> </>
); );
}; }
export default DesktopWorkspace; export default DesktopWorkspace;
@@ -1,10 +1,34 @@
import React from 'react'; import React, { ReactNode } from 'react';
import SelectionFloatingActions from '../documents/SelectionFloatingActions'; import SelectionFloatingActions from '../documents/SelectionFloatingActions';
import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel'; import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel';
import createWorkspaceSurfaceConfig from '../documents/workspaceHeader'; import createWorkspaceSurfaceConfig from '../documents/workspaceHeader';
import DocumentViewerPanel from '../preview/DocumentViewerPanel'; import DocumentViewerPanel from '../preview/DocumentViewerPanel';
import DesktopWorkspace from './DesktopWorkspace'; 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 = ({ const createDesktopSurface = ({
workspaceProps, workspaceProps,
renderSidebarToggle, renderSidebarToggle,
@@ -12,7 +36,7 @@ const createDesktopSurface = ({
onNavigateParent, onNavigateParent,
detailProps = null, detailProps = null,
detailOpen = false, detailOpen = false,
}) => { }: CreateDesktopSurfaceArgs) => {
if (!workspaceProps) { if (!workspaceProps) {
return null; return null;
} }
@@ -2,15 +2,15 @@ const DB_NAME = 'papercrate_desk';
const DB_VERSION = 1; const DB_VERSION = 1;
const LAYOUT_STORE = 'layouts'; 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) { if (currentDbPromise.value) {
return currentDbPromise.value; return currentDbPromise.value as Promise<IDBDatabase>;
} }
currentDbPromise.value = new Promise((resolve, reject) => { currentDbPromise.value = new Promise((resolve, reject) => {
const dbApi = window.indexedDB; const dbApi = typeof window !== 'undefined' ? window.indexedDB : null;
if (!dbApi) { if (!dbApi) {
reject(new Error('IndexedDB not available')); reject(new Error('IndexedDB not available'));
return; return;
@@ -39,51 +39,56 @@ const openDatabase = () => {
}; };
}); });
return currentDbPromise.value; return currentDbPromise.value as Promise<IDBDatabase>;
}; };
const requestToPromise = (request, defaultValue) => new Promise((resolve, reject) => { const requestToPromise = <T>(request: IDBRequest<T>, defaultValue: T): Promise<T> =>
request.onsuccess = () => { new Promise((resolve, reject) => {
const { result } = request; request.onsuccess = () => {
resolve(result ?? defaultValue); const { result } = request;
}; resolve(result ?? defaultValue);
request.onerror = () => { };
reject(request.error || new Error('IndexedDB request failed')); request.onerror = () => {
}; reject(request.error || new Error('IndexedDB request failed'));
}); };
});
const iterateCursor = (request, iteratee) => new Promise((resolve, reject) => { const iterateCursor = (request: IDBRequest<IDBCursorWithValue | null>, iteratee: (cursor: IDBCursorWithValue) => void) =>
request.onsuccess = (event) => { new Promise<void>((resolve, reject) => {
const cursor = event.target.result; request.onsuccess = (event) => {
if (!cursor) { 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(); resolve();
return; };
} transaction.onerror = () => {
try { reject(transaction.error || new Error('IndexedDB transaction failed'));
iteratee(cursor); };
cursor.continue(); transaction.onabort = () => {
} catch (error) { reject(transaction.error || new Error('IndexedDB transaction aborted'));
reject(error); };
} });
};
request.onerror = () => {
reject(request.error || new Error('IndexedDB cursor failed'));
};
});
const transactionComplete = (transaction) => new Promise((resolve, reject) => { type TransactionMode = 'readonly' | 'readwrite' | 'versionchange';
transaction.oncomplete = () => {
resolve();
};
transaction.onerror = () => {
reject(transaction.error || new Error('IndexedDB transaction failed'));
};
transaction.onabort = () => {
reject(transaction.error || new Error('IndexedDB transaction aborted'));
};
});
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 db = await openDatabase();
const transaction = db.transaction(LAYOUT_STORE, mode); const transaction = db.transaction(LAYOUT_STORE, mode);
const store = transaction.objectStore(LAYOUT_STORE); const store = transaction.objectStore(LAYOUT_STORE);
@@ -101,13 +106,24 @@ const withStore = async (mode, handler) => {
try { try {
await done; await done;
} catch { } catch {
// noop prefer original error // ignore
} }
throw error; 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) { if (!tenantId || !viewId) {
return []; 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) { if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) {
return; return;
} }
@@ -144,7 +160,7 @@ export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => {
rotation: Number(entry.rotation) || 0, rotation: Number(entry.rotation) || 0,
zIndex: Number(entry.zIndex) || 0, zIndex: Number(entry.zIndex) || 0,
updatedAt: entry.updatedAt || timestamp, updatedAt: entry.updatedAt || timestamp,
}); } satisfies LayoutRecord);
}); });
}); });
} catch (error) { } 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) { if (!tenantId) {
return; return;
} }
@@ -169,15 +185,17 @@ export const deleteTenantLayouts = async (tenantId) => {
} }
}; };
export const closeDeskDatabase = () => { export const closeDeskDatabase = (): void => {
if (!currentDbPromise.value) { if (!currentDbPromise.value) {
return; return;
} }
currentDbPromise.value = currentDbPromise.value.then((db) => { currentDbPromise.value = currentDbPromise.value.then((db) => {
try { if (db) {
db.close(); try {
} catch (error) { db.close();
console.warn('[desk] Failed to close IndexedDB', error); } catch (error) {
console.warn('[desk] Failed to close IndexedDB', error);
}
} }
return null; 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) { if (!event) {
return; 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) { if (!event) {
return { x: 0, y: 0 }; return { x: 0, y: 0 };
} }
@@ -1,8 +1,32 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { createAssetView } from '../../asset_manager'; import { createAssetView } from '../../asset_manager';
const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { interface DocumentLike {
const [metadataMap, setMetadataMap] = useState(() => new Map()); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -14,19 +38,19 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => {
}; };
} }
const fetchMetadataForDoc = async (doc) => { const fetchMetadataForDoc = async (doc: DocumentLike) => {
if (!doc?.id) { if (!doc?.id) {
return null; return null;
} }
const docId = String(doc.id); 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 asset = resolveAsset('preview') || resolveAsset('thumbnail');
let view = createAssetView(asset); let view = createAssetView(asset);
let metadata = view.getPrimaryMetadata(); 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?.width)) &&
Number.isFinite(Number(meta?.height)) && Number.isFinite(Number(meta?.height)) &&
Number(meta.width) > 0 && Number(meta.width) > 0 &&
-4
View File
@@ -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})`;
+8
View File
@@ -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})`;
@@ -1,4 +1,4 @@
import { safeInvoke } from '../events.js'; import { safeInvoke } from '../events';
export const CLICK_ACTIONS = { export const CLICK_ACTIONS = {
selectSingle: 'selectSingle', selectSingle: 'selectSingle',
@@ -6,20 +6,52 @@ export const CLICK_ACTIONS = {
addCard: 'addCard', addCard: 'addCard',
addStack: 'addStack', addStack: 'addStack',
none: 'none', none: 'none',
}; } as const;
export const DRAG_ACTIONS = { export const DRAG_ACTIONS = {
dragSelectSingle: 'dragSelectSingle', dragSelectSingle: 'dragSelectSingle',
dragSelection: 'dragSelection', dragSelection: 'dragSelection',
dragSelectStack: 'dragSelectStack', dragSelectStack: 'dragSelectStack',
none: 'none', 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 STACK_HIT_EPSILON = 4;
export const POINTER_DRAG_THRESHOLD_SQUARED = 16; export const POINTER_DRAG_THRESHOLD_SQUARED = 16;
export const LONG_PRESS_DURATION_MS = 450; 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 = ({ export const createPointerIntent = ({
doc, doc,
@@ -29,7 +61,7 @@ export const createPointerIntent = ({
pointerButton, pointerButton,
pointerType, pointerType,
stackHits, stackHits,
}) => { }: PointerIntentArgs): PointerIntent => {
const alreadySelected = selectedDocumentIds.includes(doc.id); const alreadySelected = selectedDocumentIds.includes(doc.id);
const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; 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) { switch (intent.clickAction) {
case CLICK_ACTIONS.selectSingle: case CLICK_ACTIONS.selectSingle:
case CLICK_ACTIONS.addCard: 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) { if (!intent || intent.clickSelectionApplied) {
return; return;
} }
@@ -108,7 +150,12 @@ export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocume
applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect }); 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) { if (!intent) {
return; return;
} }
@@ -14,7 +14,7 @@ import {
finalizeClickSelection, finalizeClickSelection,
withinThreshold, withinThreshold,
} from './pointerUtils'; } from './pointerUtils';
import { getPointerPosition, safeInvoke } from '../events.js'; import { getPointerPosition, safeInvoke } from '../events';
const buildEntryDescriptor = (docId) => ({ const buildEntryDescriptor = (docId) => ({
type: 'document', type: 'document',
@@ -3,7 +3,7 @@ import {
useEffect, useEffect,
useRef, useRef,
} from 'react'; } from 'react';
import { getPointerPosition, preventAll, safeInvoke } from '../events.js'; import { getPointerPosition, preventAll, safeInvoke } from '../events';
import { import {
isTagTransferEvent, isTagTransferEvent,
parseTagTransferPayload, parseTagTransferPayload,
@@ -1,11 +1,75 @@
import { useCallback, useMemo } from 'react'; 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 = ({ const useDeskWorkspaceProps = ({
documents, documents,
searchResults, searchResults,
breadcrumbs, breadcrumbs,
currentFolderName, currentFolderName,
documentsViewMode, documentsViewMode = 'desk',
handleDocumentsViewModeChange, handleDocumentsViewModeChange,
handleDeskExit, handleDeskExit,
refreshCurrentFolder, refreshCurrentFolder,
@@ -23,7 +87,7 @@ const useDeskWorkspaceProps = ({
handleTagRemove, handleTagRemove,
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
activeTagFilters, activeTagFilters = [],
handleDeleteSelection, handleDeleteSelection,
tags, tags,
correspondents, correspondents,
@@ -38,17 +102,17 @@ const useDeskWorkspaceProps = ({
moveDocumentsToFolder, moveDocumentsToFolder,
searchIncludeDescendants, searchIncludeDescendants,
toggleSearchIncludeDescendants, toggleSearchIncludeDescendants,
selectedEntries, selectedEntries = [],
selectionAnchorRef, selectionAnchorRef,
applySelection, applySelection,
resolveDocumentRowKey, resolveDocumentRowKey,
showingSearchResults, showingSearchResults = false,
searchQuery, searchQuery = '',
activeCorrespondentFilters, activeCorrespondentFilters = [],
selectedFolder, selectedFolder,
openDetailPanel, openDetailPanel,
}) => { }: UseDeskWorkspacePropsArgs) => {
const handleDeskDocumentStackSelect = useCallback( const handleDeskDocumentStackSelect: DeskDocumentStackSelectHandler = useCallback(
(docIds) => { (docIds) => {
if (!Array.isArray(docIds) || docIds.length === 0) { if (!Array.isArray(docIds) || docIds.length === 0) {
return; return;
@@ -56,7 +120,7 @@ const useDeskWorkspaceProps = ({
const rowKeys = docIds const rowKeys = docIds
.map((id) => resolveDocumentRowKey(id)) .map((id) => resolveDocumentRowKey(id))
.filter(Boolean); .filter((value): value is Identifier | string => Boolean(value));
if (!rowKeys.length) { if (!rowKeys.length) {
return; return;
@@ -69,9 +133,9 @@ const useDeskWorkspaceProps = ({
} }
}); });
const anchor = rowKeys[0] const anchor = (rowKeys[0]
|| selectionAnchorRef.current || selectionAnchorRef.current
|| nextKeys[nextKeys.length - 1]; || nextKeys[nextKeys.length - 1]) as Identifier | string | null;
applySelection(nextKeys, { applySelection(nextKeys, {
anchor, anchor,
@@ -82,13 +146,13 @@ const useDeskWorkspaceProps = ({
); );
const handleDeskDocumentOpen = useCallback( const handleDeskDocumentOpen = useCallback(
(docId, { useSelection = false } = {}) => { (docId: Identifier | undefined, { useSelection = false }: { useSelection?: boolean } = {}) => {
const selectionDocIds = Array.isArray(selectedDocumentIds) const selectionDocIds = Array.isArray(selectedDocumentIds)
? 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; targetIds = selectionDocIds;
} else if (selectionDocIds.length) { } else if (selectionDocIds.length) {
targetIds = selectionDocIds; targetIds = selectionDocIds;
@@ -100,7 +164,7 @@ const useDeskWorkspaceProps = ({
return; return;
} }
openDetailPanel({ documentIds: targetIds }); openDetailPanel?.({ documentIds: targetIds });
}, },
[openDetailPanel, selectedDocumentIds], [openDetailPanel, selectedDocumentIds],
); );
@@ -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 { preventAll, safeInvoke } from './events';
import { clamp } from './math'; import { clamp } from './math';
import usePointerTap from '../ui/usePointerTap'; 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_PX = 4;
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX; const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
const EDGE_COLLISION_THRESHOLD = 0.5; const EDGE_COLLISION_THRESHOLD = 0.5;
const getEventTargetElement = (event) => { const getEventTargetElement = (event?: PointerEventLike | null): Element | null => {
if (!event) { if (!event) {
return null; return null;
} }
const ElementCtor = window.Element; const ElementCtor = typeof window !== 'undefined' ? window.Element : null;
if (!ElementCtor) { if (!ElementCtor) {
return null; 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; return candidate instanceof ElementCtor ? candidate : null;
}; };
const useDocumentDrag = (options = {}) => { const useDocumentDrag = (options: UseDocumentDragOptions) => {
const { const {
engine, engine,
layoutRef, layoutRef,
@@ -35,13 +188,16 @@ const useDocumentDrag = (options = {}) => {
openOverlayForDoc, openOverlayForDoc,
recalcVisibleDocIds, recalcVisibleDocIds,
settings, settings,
containerRef, containerRef: providedContainerRef,
onInspectDocument, onInspectDocument,
onDocumentStackSelect, onDocumentStackSelect,
selectedDocumentIds, selectedDocumentIds = [],
markLayoutDirty, markLayoutDirty,
} = options; } = options;
const fallbackContainerRef = useRef<HTMLElement | null>(null);
const containerRef = providedContainerRef ?? fallbackContainerRef;
const { const {
canvasPadding = 24, canvasPadding = 24,
defaultCanvasWidth = 1024, defaultCanvasWidth = 1024,
@@ -56,23 +212,23 @@ const useDocumentDrag = (options = {}) => {
[engine], [engine],
); );
const tapHandler = usePointerTap({ const tapHandler = usePointerTap<DragTapMetadata>({
delay: 220, delay: 220,
onSingle: () => {}, onSingle: () => {},
onDouble: ({ data, event }) => { onDouble: ({ data, event }) => {
if (!data || !data.docId) { if (!data?.docId) {
return; return;
} }
if (event?.altKey) { if (event?.altKey) {
openOverlayForDoc(data.docId, data.originInfo); openOverlayForDoc?.(data.docId, data.originInfo);
return; return;
} }
onInspectDocument?.(data.docId, event); 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) { if (!docKey) {
return; return;
} }
@@ -80,7 +236,11 @@ const useDocumentDrag = (options = {}) => {
if (!map) { if (!map) {
return; return;
} }
map.set(String(docKey), transform); if (transform) {
map.set(String(docKey), transform);
} else {
map.delete(String(docKey));
}
}, [dragTransformsRef]); }, [dragTransformsRef]);
const clearDragTransforms = useCallback(() => { const clearDragTransforms = useCallback(() => {
@@ -91,13 +251,15 @@ const useDocumentDrag = (options = {}) => {
map.clear(); map.clear();
}, [dragTransformsRef]); }, [dragTransformsRef]);
const commitActiveDragTransforms = useCallback((docIds = null) => { const commitActiveDragTransforms = useCallback((docIds: Array<Identifier | null | undefined> | null = null) => {
const map = dragTransformsRef?.current; const map = dragTransformsRef?.current;
if (!map || !map.size) { if (!map || !map.size) {
return; return;
} }
const keys = Array.isArray(docIds) && docIds.length 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()); : Array.from(map.keys());
keys.forEach((key) => { keys.forEach((key) => {
const transform = map.get(key); const transform = map.get(key);
@@ -116,7 +278,7 @@ const useDocumentDrag = (options = {}) => {
}, [dragTransformsRef, layoutRef, markLayoutDirty]); }, [dragTransformsRef, layoutRef, markLayoutDirty]);
const finishDrag = useCallback( const finishDrag = useCallback(
(pointerId, { clearTransforms = true } = {}) => { (pointerId: number, { clearTransforms = true }: { clearTransforms?: boolean } = {}) => {
const state = dragStateRef.current; const state = dragStateRef.current;
if (state && state.pointerId === pointerId) { if (state && state.pointerId === pointerId) {
const capturedTarget = state.capturedTarget; const capturedTarget = state.capturedTarget;
@@ -141,7 +303,7 @@ const useDocumentDrag = (options = {}) => {
); );
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(event, docIdInput, options = {}) => { (event: PointerEventLike, docIdInput?: Identifier | null, options: PointerDownOptions = {}) => {
const targetElement = getEventTargetElement(event); const targetElement = getEventTargetElement(event);
if (targetElement?.closest && targetElement.closest('[data-desk-tag-chip="true"]')) { if (targetElement?.closest && targetElement.closest('[data-desk-tag-chip="true"]')) {
return; return;
@@ -165,7 +327,7 @@ const useDocumentDrag = (options = {}) => {
const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw) const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw)
? stackDocIdsOptionRaw ? stackDocIdsOptionRaw
.map((value) => (value != null ? String(value) : null)) .map((value) => (value != null ? String(value) : null))
.filter(Boolean) .filter((value): value is string => Boolean(value))
: null; : null;
const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied); const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied);
const wasSelectedAtPointerDown = Boolean(options?.wasSelected); const wasSelectedAtPointerDown = Boolean(options?.wasSelected);
@@ -173,8 +335,10 @@ const useDocumentDrag = (options = {}) => {
options?.modifierActive ?? Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); options?.modifierActive ?? Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
const stackReplace = Boolean(options?.stackReplace); const stackReplace = Boolean(options?.stackReplace);
let selectionIds = Array.isArray(selectedDocumentIds) let selectionIds: string[] = Array.isArray(selectedDocumentIds)
? selectedDocumentIds.map((id) => String(id)) ? selectedDocumentIds
.map((id) => (id != null ? String(id) : null))
.filter((id): id is string => Boolean(id))
: []; : [];
if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) { if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) {
@@ -196,9 +360,7 @@ const useDocumentDrag = (options = {}) => {
selectionIds = [...selectionIds, docKey]; selectionIds = [...selectionIds, docKey];
} }
selectionIds = selectionIds selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
.map((id) => String(id))
.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
if (!selectionIds.includes(docKey)) { if (!selectionIds.includes(docKey)) {
selectionIds.unshift(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 containerLeft = containerRect?.left || 0;
const containerTop = containerRect?.top || 0; const containerTop = containerRect?.top || 0;
const pointerCanvasX = event.clientX - containerLeft; const pointerCanvasX = event.clientX - containerLeft;
@@ -280,7 +442,7 @@ const useDocumentDrag = (options = {}) => {
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial; const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial; const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
const groupItems = selectionIds.map((id) => { const groupItems: DragGroupItemInternal[] = selectionIds.map((id) => {
const itemDoc = documentLookup.get(id); const itemDoc = documentLookup.get(id);
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo; const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
const itemWidth = itemSize.width || docWidth; const itemWidth = itemSize.width || docWidth;
@@ -307,7 +469,7 @@ const useDocumentDrag = (options = {}) => {
initialRotation, initialRotation,
displayRotation: initialRotation, displayRotation: initialRotation,
targetRotation, targetRotation,
}; } satisfies DragGroupItemInternal;
}); });
const eventTimestamp = const eventTimestamp =
@@ -354,9 +516,12 @@ const useDocumentDrag = (options = {}) => {
stackDocIds: hasStackSource ? stackDocIdsOption : null, stackDocIds: hasStackSource ? stackDocIdsOption : null,
stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource, stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource,
stackReplace, stackReplace,
}; } satisfies DragStateInternal;
const state = dragStateRef.current; const state = dragStateRef.current;
if (!state) {
return;
}
clearDragTransforms(); clearDragTransforms();
state.groupItems.forEach((item) => { state.groupItems.forEach((item) => {
@@ -416,7 +581,7 @@ const useDocumentDrag = (options = {}) => {
]); ]);
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(event) => { (event: PointerEventLike) => {
const state = dragStateRef.current; const state = dragStateRef.current;
if (!state) { if (!state) {
return; return;
@@ -427,7 +592,7 @@ const useDocumentDrag = (options = {}) => {
preventAll(event); preventAll(event);
if (state.isGroup) { if (state.isGroup) {
const containerRect = containerRef?.current?.getBoundingClientRect?.(); const containerRect = containerRef.current?.getBoundingClientRect?.();
if (containerRect) { if (containerRect) {
state.containerRectLeft = containerRect.left; state.containerRectLeft = containerRect.left;
state.containerRectTop = containerRect.top; state.containerRectTop = containerRect.top;
@@ -449,7 +614,9 @@ const useDocumentDrag = (options = {}) => {
&& Array.isArray(state.stackDocIds) && Array.isArray(state.stackDocIds)
&& state.stackDocIds.length > 0 && 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; state.stackSelectionApplied = true;
} }
if (!state.groupElevated) { if (!state.groupElevated) {
@@ -561,7 +728,7 @@ const useDocumentDrag = (options = {}) => {
const halfWidth = docWidth / 2; const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2; const halfHeight = docHeight / 2;
const containerRect = containerRef?.current?.getBoundingClientRect?.(); const containerRect = containerRef.current?.getBoundingClientRect?.();
if (containerRect) { if (containerRect) {
state.containerRectLeft = containerRect.left; state.containerRectLeft = containerRect.left;
state.containerRectTop = containerRect.top; state.containerRectTop = containerRect.top;
@@ -712,7 +879,7 @@ const useDocumentDrag = (options = {}) => {
); );
const handlePointerUp = useCallback( const handlePointerUp = useCallback(
(event) => { (event: PointerEventLike) => {
const state = dragStateRef.current; const state = dragStateRef.current;
if (!state || state.pointerId !== event.pointerId) { if (!state || state.pointerId !== event.pointerId) {
finishDrag(event.pointerId); finishDrag(event.pointerId);
@@ -729,7 +896,7 @@ const useDocumentDrag = (options = {}) => {
if (state.moved) { if (state.moved) {
commitActiveDragTransforms([state.docKey]); commitActiveDragTransforms([state.docKey]);
const inertiaState = { const inertiaState: EngineInertiaState = {
restRotation: state.restRotation, restRotation: state.restRotation,
dynamicRotation: state.dynamicRotation, dynamicRotation: state.dynamicRotation,
angularVelocity: state.angularVelocity, angularVelocity: state.angularVelocity,
@@ -776,7 +943,7 @@ const useDocumentDrag = (options = {}) => {
); );
const handlePointerCancel = useCallback( const handlePointerCancel = useCallback(
(event) => { (event: PointerEventLike) => {
const state = dragStateRef.current; const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId && state.moved) { if (state && state.pointerId === event.pointerId && state.moved) {
if (state.isGroup) { if (state.isGroup) {
@@ -788,7 +955,7 @@ const useDocumentDrag = (options = {}) => {
} }
commitActiveDragTransforms([state.docKey]); commitActiveDragTransforms([state.docKey]);
const inertiaState = { const inertiaState: EngineInertiaState = {
restRotation: state.restRotation, restRotation: state.restRotation,
dynamicRotation: state.dynamicRotation, dynamicRotation: state.dynamicRotation,
angularVelocity: state.angularVelocity, angularVelocity: state.angularVelocity,
@@ -1,5 +1,131 @@
import { clamp, formatTransform } from './math.js'; import { clamp, formatTransform } from './math';
import { fetchLayoutRecords, upsertLayoutRecords } from './db.js'; 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_CANVAS_PADDING = 24;
export const DESK_ROTATION_RANGE = 7; 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 SETTLE_ANGULAR_VELOCITY = 1.2;
export const applyDomTransform = ( export const applyDomTransform = (
node, node: HTMLElement | null | undefined,
{ {
centerX, centerX,
centerY, centerY,
@@ -27,8 +153,8 @@ export const applyDomTransform = (
rotation = 0, rotation = 0,
scale = 1, scale = 1,
zIndex, zIndex,
} = {}, }: TransformOptions = {},
) => { ): void => {
if (!node) { if (!node) {
return; 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 w = Number(width);
const h = Number(height); 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 high = Math.min(DESK_CARD_MAX / w, DESK_CARD_MAX / h);
const candidates = []; const candidates = [];
const addCandidate = (scale) => { const addCandidate = (scale: number) => {
if (Number.isFinite(scale) && scale > 0) { if (Number.isFinite(scale) && scale > 0) {
candidates.push(scale); candidates.push(scale);
} }
@@ -66,7 +192,7 @@ export const clampCardDimensions = (width, height) => {
addCandidate(low); addCandidate(low);
addCandidate(high); 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 scaledWidth = w * scale;
const scaledHeight = h * scale; const scaledHeight = h * scale;
const violation = Math.max( 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 baseSeed = seededRandom(`${docId}:fallback-size`);
const aspectSeed = seededRandom(`${docId}:fallback-aspect`); const aspectSeed = seededRandom(`${docId}:fallback-aspect`);
@@ -105,7 +231,7 @@ export const computeFallbackCardSize = (docId) => {
return clampCardDimensions(width, height); return clampCardDimensions(width, height);
}; };
function seededRandom(input) { function seededRandom(input: unknown): number {
const text = String(input); const text = String(input);
let hash = 2166136261; let hash = 2166136261;
for (let index = 0; index < text.length; index += 1) { for (let index = 0; index < text.length; index += 1) {
@@ -115,22 +241,25 @@ function seededRandom(input) {
return (hash >>> 0) / 4294967295; return (hash >>> 0) / 4294967295;
} }
function randomRangeFromSeed(seedKey, min, max) { function randomRangeFromSeed(seedKey: string, min: number, max: number): number {
const span = max - min; const span = max - min;
if (span <= 0) return min; if (span <= 0) return min;
const seed = seededRandom(seedKey); const seed = seededRandom(seedKey);
return min + seed * span; return min + seed * span;
} }
function buildKey(docId, suffix) { function buildKey(docId: string | number, suffix: string): string {
return `${docId}::${suffix}`; 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.x - edgeStart.x) * (point.y - edgeStart.y)
- (edgeEnd.y - edgeStart.y) * (point.x - edgeStart.x); - (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) { if (!Array.isArray(polygon) || polygon.length === 0) {
return; 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) { if (!Array.isArray(polygon) || polygon.length === 0) {
return; 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 A1 = p2.y - p1.y;
const B1 = p1.x - p2.x; const B1 = p1.x - p2.x;
const C1 = A1 * p1.x + B1 * p1.y; 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) { if (!Array.isArray(subject) || !subject.length) {
return []; return [];
} }
@@ -204,7 +336,7 @@ const clipPolygon = (subject, clipper) => {
return output; return output;
}; };
const isPointInsideConvex = (point, polygon) => { const isPointInsideConvex = (point: Point, polygon: Polygon): boolean => {
if (!polygon?.length) { if (!polygon?.length) {
return false; return false;
} }
@@ -229,7 +361,7 @@ const isPointInsideConvex = (point, polygon) => {
return inside; return inside;
}; };
const polygonCentroid = (polygon) => { const polygonCentroid = (polygon: Polygon): Point => {
if (!polygon?.length) { if (!polygon?.length) {
return { x: 0, y: 0 }; return { x: 0, y: 0 };
} }
@@ -261,7 +393,7 @@ const polygonCentroid = (polygon) => {
}; };
}; };
const generateInitialLayout = ( const generateInitialLayout = (
entries, entries: LayoutGenerationEntry[],
{ {
canvasWidth, canvasWidth,
canvasHeight, canvasHeight,
@@ -270,9 +402,9 @@ const generateInitialLayout = (
rotationRange = DESK_ROTATION_RANGE, rotationRange = DESK_ROTATION_RANGE,
minSpacing = 48, minSpacing = 48,
shelfWidth = 0, shelfWidth = 0,
}, }: LayoutGenerationOptions,
) => { ): { layout: Map<string, LayoutEntry>; maxZ: number } => {
const layout = new Map(); const layout = new Map<string, LayoutEntry>();
let currentZ = startZ; let currentZ = startZ;
let maxZ = startZ; let maxZ = startZ;
@@ -282,9 +414,9 @@ const generateInitialLayout = (
const shelfOffset = Math.max(shelfWidth, 0); const shelfOffset = Math.max(shelfWidth, 0);
const spacingBuffer = Math.max(minSpacing, 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 halfWidth = width / 2;
const halfHeight = height / 2; const halfHeight = height / 2;
return { return {
@@ -298,7 +430,7 @@ const generateInitialLayout = (
}; };
}; };
const evaluateCandidateSpacing = (x, y, radius) => { const evaluateCandidateSpacing = (x: number, y: number, radius: number) => {
if (!placed.length) { if (!placed.length) {
return Number.POSITIVE_INFINITY; return Number.POSITIVE_INFINITY;
} }
@@ -381,11 +513,71 @@ const generateInitialLayout = (
}; };
export class WorkspaceEngine { 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({ constructor({
allowLayoutPersistence = false, allowLayoutPersistence = false,
tenantId = null, tenantId = null,
viewId = null, viewId = null,
} = {}) { }: WorkspaceEngineOptions = {}) {
this.allowLayoutPersistence = allowLayoutPersistence; this.allowLayoutPersistence = allowLayoutPersistence;
this.tenantId = tenantId; this.tenantId = tenantId;
this.viewId = viewId; this.viewId = viewId;
@@ -424,7 +616,7 @@ export class WorkspaceEngine {
this.initialLoadDone = false; this.initialLoadDone = false;
} }
updateConfig({ allowLayoutPersistence, tenantId, viewId }) { updateConfig({ allowLayoutPersistence, tenantId, viewId }: WorkspaceEngineOptions): void {
const allowChanged = const allowChanged =
typeof allowLayoutPersistence === 'boolean' typeof allowLayoutPersistence === 'boolean'
&& allowLayoutPersistence !== this.allowLayoutPersistence; && allowLayoutPersistence !== this.allowLayoutPersistence;
@@ -470,7 +662,7 @@ export class WorkspaceEngine {
} }
} }
setItems(items) { setItems(items: DeskDocument[] | null | undefined): void {
const normalized = Array.isArray(items) ? items : []; const normalized = Array.isArray(items) ? items : [];
this.items = normalized; this.items = normalized;
const canGenerateLayoutImmediately = const canGenerateLayoutImmediately =
@@ -484,28 +676,28 @@ export class WorkspaceEngine {
this.recalcVisibleDocIds(); this.recalcVisibleDocIds();
} }
setDocumentLookup(map) { setDocumentLookup(map: Map<string, DeskDocument>): void {
this.documentLookup = map instanceof Map ? map : new Map(); this.documentLookup = map instanceof Map ? map : new Map();
this.recalcVisibleDocIds(); this.recalcVisibleDocIds();
} }
setEnsureDocumentSize(fn) { setEnsureDocumentSize(fn: EnsureDocumentSize): void {
if (typeof fn === 'function') { if (typeof fn === 'function') {
this.ensureDocumentSize = fn; this.ensureDocumentSize = fn;
} }
} }
setResolveBaseMetrics(fn) { setResolveBaseMetrics(fn: ResolveBaseMetrics): void {
if (typeof fn === 'function') { if (typeof fn === 'function') {
this.resolveBaseMetrics = fn; this.resolveBaseMetrics = fn;
} }
} }
setItemRefs(ref) { setItemRefs(ref: ItemRefs | null | undefined): void {
this.itemRefs = ref || { current: new Map() }; this.itemRefs = ref || { current: new Map() };
} }
setCanvasSize(size) { setCanvasSize(size: { width?: number | null; height?: number | null }): void {
const width = Number(size?.width) || 0; const width = Number(size?.width) || 0;
const height = Number(size?.height) || 0; const height = Number(size?.height) || 0;
if (this.canvasSize.width === width && this.canvasSize.height === height) { if (this.canvasSize.width === width && this.canvasSize.height === height) {
@@ -517,7 +709,7 @@ export class WorkspaceEngine {
this.emit(); this.emit();
} }
setDraggingId(docId) { setDraggingId(docId: string | number | null): void {
const normalized = docId != null ? String(docId) : null; const normalized = docId != null ? String(docId) : null;
if (this.draggingId === normalized) { if (this.draggingId === normalized) {
return; return;
@@ -526,7 +718,7 @@ export class WorkspaceEngine {
this.emit(); this.emit();
} }
beginDrag(docIds = []) { beginDrag(docIds: Array<string | number | null> = []): void {
this.dragInProgress = true; this.dragInProgress = true;
if (Array.isArray(docIds)) { if (Array.isArray(docIds)) {
this.activeDragDocIds = new Set(docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean)); 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.dragInProgress = false;
this.activeDragDocIds.clear(); this.activeDragDocIds.clear();
this.flushPendingLayoutOps(); this.flushPendingLayoutOps();
} }
flushPendingLayoutOps() { flushPendingLayoutOps(): void {
if (this.pendingSnapshotSync) { if (this.pendingSnapshotSync) {
this.syncLayoutSnapshot(); this.syncLayoutSnapshot();
} }
@@ -550,7 +742,7 @@ export class WorkspaceEngine {
} }
} }
setTagDropTargetId(docId) { setTagDropTargetId(docId: string | number | null): void {
const normalized = docId != null ? String(docId) : null; const normalized = docId != null ? String(docId) : null;
if (this.tagDropTargetId === normalized) { if (this.tagDropTargetId === normalized) {
return; return;
@@ -559,7 +751,7 @@ export class WorkspaceEngine {
this.emit(); this.emit();
} }
setPendingTagDocId(docId) { setPendingTagDocId(docId: string | number | null): void {
const normalized = docId != null ? String(docId) : null; const normalized = docId != null ? String(docId) : null;
if (this.pendingTagDocId === normalized) { if (this.pendingTagDocId === normalized) {
return; return;
@@ -568,7 +760,7 @@ export class WorkspaceEngine {
this.emit(); this.emit();
} }
setPendingRemovalTag(payload) { setPendingRemovalTag(payload: unknown): void {
if (payload === this.pendingRemovalTag) { if (payload === this.pendingRemovalTag) {
return; return;
} }
@@ -576,11 +768,11 @@ export class WorkspaceEngine {
this.emit(); this.emit();
} }
markLayoutDirty() { markLayoutDirty(): void {
this.layoutDirty = true; this.layoutDirty = true;
} }
getLayout(docId) { getLayout(docId: string | number | null): LayoutEntry | null {
if (docId == null) { if (docId == null) {
return null; return null;
} }
@@ -588,13 +780,20 @@ export class WorkspaceEngine {
return this.layout.get(key) || null; 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) { if (docId == null) {
return; return;
} }
const key = String(docId); const key = String(docId);
const previous = this.layout.get(key) || null; 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) { if (!next) {
this.layout.delete(key); this.layout.delete(key);
} else { } else {
@@ -605,7 +804,7 @@ export class WorkspaceEngine {
this.persistLayoutSnapshot(); this.persistLayoutSnapshot();
} }
bringToFront(docId) { bringToFront(docId: string | number | null): void {
if (docId == null) { if (docId == null) {
return; return;
} }
@@ -622,7 +821,16 @@ export class WorkspaceEngine {
this.recalcVisibleDocIds(); 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; const key = docId != null ? String(docId) : null;
if (!key) { if (!key) {
return; return;
@@ -639,7 +847,7 @@ export class WorkspaceEngine {
}); });
} }
finalizeGroupDrag(dragState) { finalizeGroupDrag(dragState: DragState): void {
if (!dragState?.groupItems) { if (!dragState?.groupItems) {
return; return;
} }
@@ -652,16 +860,18 @@ export class WorkspaceEngine {
if (!key) { if (!key) {
return; return;
} }
const entry = this.layout.get(key) || {}; const entry = this.layout.get(key);
const centerX = item.currentCenterX ?? entry.centerX ?? dragState.originCenterX; const centerX = item.currentCenterX ?? entry?.centerX ?? dragState.originCenterX ?? 0;
const centerY = item.currentCenterY ?? entry.centerY ?? dragState.originCenterY; const centerY = item.currentCenterY ?? entry?.centerY ?? dragState.originCenterY ?? 0;
const rotation = item.displayRotation ?? entry.rotation ?? 0; const rotation = item.displayRotation ?? entry?.rotation ?? 0;
const nextEntry = { const nextEntry: LayoutEntry = {
...entry,
centerX, centerX,
centerY, centerY,
rotation, rotation,
z: entry?.z ?? this.zCounter,
width: entry?.width ?? item.width,
height: entry?.height ?? item.height,
}; };
this.layout.set(key, nextEntry); this.layout.set(key, nextEntry);
@@ -683,7 +893,7 @@ export class WorkspaceEngine {
this.persistLayoutSnapshot(); this.persistLayoutSnapshot();
} }
cancelInertiaAnimation(docId) { cancelInertiaAnimation(docId: string | number | null): void {
const key = docId != null ? String(docId) : null; const key = docId != null ? String(docId) : null;
if (!key) { if (!key) {
return; return;
@@ -695,7 +905,7 @@ export class WorkspaceEngine {
this.inertiaAnimations.delete(key); this.inertiaAnimations.delete(key);
} }
disposeInertiaAnimations() { disposeInertiaAnimations(): void {
this.inertiaAnimations.forEach((animation) => { this.inertiaAnimations.forEach((animation) => {
if (animation?.frameId != null) { if (animation?.frameId != null) {
window.cancelAnimationFrame(animation.frameId); window.cancelAnimationFrame(animation.frameId);
@@ -704,7 +914,12 @@ export class WorkspaceEngine {
this.inertiaAnimations.clear(); 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; const key = simulationState.docId != null ? String(simulationState.docId) : null;
if (!key) { if (!key) {
return true; return true;
@@ -761,7 +976,7 @@ export class WorkspaceEngine {
return isSettled; return isSettled;
} }
startInertiaAnimation(docId, baseState) { startInertiaAnimation(docId: string | number | null, baseState: InertiaSimulationState): void {
const raf = window.requestAnimationFrame; const raf = window.requestAnimationFrame;
if (!raf) { if (!raf) {
return; return;
@@ -782,7 +997,7 @@ export class WorkspaceEngine {
lastTimestamp: now, lastTimestamp: now,
}; };
const step = (timestamp) => { const step = (timestamp: number) => {
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16; const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
const previous = simulationState.lastTimestamp; const previous = simulationState.lastTimestamp;
let dt = (safeTimestamp - previous) / 1000; let dt = (safeTimestamp - previous) / 1000;
@@ -806,7 +1021,7 @@ export class WorkspaceEngine {
this.inertiaAnimations.set(key, simulationState); this.inertiaAnimations.set(key, simulationState);
} }
syncLayoutSnapshot() { syncLayoutSnapshot(): void {
if (this.dragInProgress) { if (this.dragInProgress) {
this.pendingSnapshotSync = true; this.pendingSnapshotSync = true;
return; return;
@@ -816,7 +1031,7 @@ export class WorkspaceEngine {
this.emit(); this.emit();
} }
async persistLayoutSnapshot() { async persistLayoutSnapshot(): Promise<void> {
if (this.dragInProgress) { if (this.dragInProgress) {
this.pendingPersistSync = true; this.pendingPersistSync = true;
return; return;
@@ -883,7 +1098,7 @@ export class WorkspaceEngine {
}, 100); }, 100);
} }
ensureLayoutForItems() { ensureLayoutForItems(): void {
const persistenceReady = !this.allowLayoutPersistence || !this.tenantId || !this.viewId || this.initialLoadDone; const persistenceReady = !this.allowLayoutPersistence || !this.tenantId || !this.viewId || this.initialLoadDone;
const canvasReady = Boolean(this.canvasSize.width && this.canvasSize.height); const canvasReady = Boolean(this.canvasSize.width && this.canvasSize.height);
const sizesReady = !this.items.some((doc) => !this.ensureDocumentSize(doc)); const sizesReady = !this.items.some((doc) => !this.ensureDocumentSize(doc));
@@ -907,11 +1122,11 @@ export class WorkspaceEngine {
return; return;
} }
const next = new Map(); const next = new Map<DocumentId, LayoutEntry>();
let maxZ = this.zCounter; let maxZ = this.zCounter;
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH; const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT; const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT;
const docsNeedingLayout = []; const docsNeedingLayout: LayoutGenerationEntry[] = [];
const currentEntries = new Map(this.layout); const currentEntries = new Map(this.layout);
@@ -984,7 +1199,7 @@ export class WorkspaceEngine {
this.recalcVisibleDocIds(); this.recalcVisibleDocIds();
} }
recalcVisibleDocIds() { recalcVisibleDocIds(): void {
const ensureSize = this.ensureDocumentSize; const ensureSize = this.ensureDocumentSize;
if (typeof ensureSize !== 'function') { if (typeof ensureSize !== 'function') {
return; return;
@@ -1009,7 +1224,7 @@ export class WorkspaceEngine {
{ x: 0, y: canvasHeight }, { x: 0, y: canvasHeight },
]; ];
const entries = []; const entries: Array<{ key: string; z: number; polygon: Polygon }> = [];
layoutMap.forEach((entry, docKey) => { layoutMap.forEach((entry, docKey) => {
if (!docKey) { if (!docKey) {
return; return;
@@ -1062,8 +1277,8 @@ export class WorkspaceEngine {
entries.sort((a, b) => (b.z || 0) - (a.z || 0)); entries.sort((a, b) => (b.z || 0) - (a.z || 0));
const visiblePolygons = []; const visiblePolygons: Polygon[] = [];
const result = new Set(); const result = new Set<DocumentId>();
entries.forEach(({ key, polygon }) => { entries.forEach(({ key, polygon }) => {
if (polygon.length < 3) { if (polygon.length < 3) {
@@ -1117,16 +1332,16 @@ export class WorkspaceEngine {
this.emit(); this.emit();
} }
subscribe(listener) { subscribe(listener: WorkspaceSubscriber): () => void {
this.subscribers.add(listener); this.subscribers.add(listener);
return () => { return () => {
this.subscribers.delete(listener); this.subscribers.delete(listener);
}; };
} }
getSnapshot = () => this.snapshotCache; getSnapshot = (): WorkspaceSnapshot => this.snapshotCache;
buildSnapshot() { buildSnapshot(): WorkspaceSnapshot {
return { return {
layout: this.layoutSnapshot, layout: this.layoutSnapshot,
canvasSize: this.canvasSize, canvasSize: this.canvasSize,
@@ -1139,7 +1354,7 @@ export class WorkspaceEngine {
}; };
} }
emit() { emit(): void {
this.snapshotCache = this.buildSnapshot(); this.snapshotCache = this.buildSnapshot();
this.subscribers.forEach((listener) => { this.subscribers.forEach((listener) => {
try { try {
@@ -1150,7 +1365,7 @@ export class WorkspaceEngine {
}); });
} }
async loadPersistedLayout() { async loadPersistedLayout(): Promise<void> {
if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) { if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) {
return; return;
} }
@@ -1198,7 +1413,10 @@ export class WorkspaceEngine {
} }
} }
export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => { export const useWorkspaceSnapshot = (
engine: WorkspaceEngine,
useSyncExternalStoreHook: UseSyncExternalStoreHook,
): WorkspaceSnapshot => {
const useSyncExternalStore = useSyncExternalStoreHook; const useSyncExternalStore = useSyncExternalStoreHook;
if (typeof useSyncExternalStore !== 'function') { if (typeof useSyncExternalStore !== 'function') {
throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook'); throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook');
@@ -1211,7 +1429,9 @@ export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => {
}; };
/* istanbul ignore next */ /* istanbul ignore next */
const commonJsModule = globalThis?.module; const commonJsModule = (globalThis as typeof globalThis & {
module?: { exports?: Record<string, unknown> };
}).module;
if (commonJsModule?.exports) { if (commonJsModule?.exports) {
commonJsModule.exports = { commonJsModule.exports = {
WorkspaceEngine, WorkspaceEngine,
@@ -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 { createPortal } from 'react-dom';
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
import { clamp } from '../utils/math'; 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 noop = () => {};
const PreviewZoomOverlay = ({ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
open = false, open = false,
display = null, display = null,
onClose = noop, onClose = noop,
}) => { }) => {
const portalTarget = document.body; const portalTarget = typeof document !== 'undefined' ? document.body : null;
const [isNativeScale, setIsNativeScale] = useState(false); 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 [renderBackdrop, setRenderBackdrop] = useState(false);
const [isBackdropVisible, setBackdropVisible] = useState(false); const [isBackdropVisible, setBackdropVisible] = useState(false);
const [displaySnapshot, setDisplaySnapshot] = useState(null); const [displaySnapshot, setDisplaySnapshot] = useState<PreviewNavigator | null>(null);
const scrollRef = useRef(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef(null); const imageRef = useRef<HTMLImageElement | null>(null);
const focusRef = useRef(null); const focusRef = useRef<FocusPoint>(null);
const previouslyFocusedRef = useRef(null); const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const visibilityTimerRef = useRef(null); const visibilityTimerRef = useRef<number | null>(null);
const displayTimerRef = useRef(null); const displayTimerRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
if (display?.url) { if (display?.url) {
@@ -55,7 +73,7 @@ const PreviewZoomOverlay = ({
} }
setBackdropVisible(false); setBackdropVisible(false);
visibilityTimerRef.current = setTimeout(() => { visibilityTimerRef.current = window.setTimeout(() => {
setRenderBackdrop(false); setRenderBackdrop(false);
}, 260); }, 260);
@@ -117,6 +135,9 @@ const PreviewZoomOverlay = ({
}, [open, isNativeScale, naturalSize.width, naturalSize.height]); }, [open, isNativeScale, naturalSize.width, naturalSize.height]);
useEffect(() => { useEffect(() => {
if (typeof document === 'undefined') {
return;
}
if (!open) { if (!open) {
previouslyFocusedRef.current?.focus?.(); previouslyFocusedRef.current?.focus?.();
previouslyFocusedRef.current = null; previouslyFocusedRef.current = null;
@@ -124,7 +145,7 @@ const PreviewZoomOverlay = ({
} }
const active = document.activeElement; const active = document.activeElement;
previouslyFocusedRef.current = active?.focus ? active : null; previouslyFocusedRef.current = active instanceof HTMLElement ? active : null;
}, [open]); }, [open]);
const activeDisplay = open && display?.url ? display : displaySnapshot; const activeDisplay = open && display?.url ? display : displaySnapshot;
@@ -154,7 +175,7 @@ const PreviewZoomOverlay = ({
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [renderBackdrop, activeDisplay?.url]); }, [renderBackdrop, activeDisplay?.url]);
const handleKeyDown = (event) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
event.stopPropagation(); event.stopPropagation();
if (!open) { if (!open) {
@@ -203,7 +224,7 @@ const PreviewZoomOverlay = ({
} }
}; };
const toggleZoomAtPoint = (clientX, clientY) => { const toggleZoomAtPoint = (clientX: number, clientY: number) => {
const img = imageRef.current; const img = imageRef.current;
setIsNativeScale((current) => { setIsNativeScale((current) => {
if (!current && img) { if (!current && img) {
@@ -221,7 +242,7 @@ const PreviewZoomOverlay = ({
}); });
}; };
const handleImageClick = (event) => { const handleImageClick = (event: React.MouseEvent<HTMLImageElement>) => {
event.stopPropagation(); event.stopPropagation();
toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0); toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0);
}; };
@@ -232,11 +253,7 @@ const PreviewZoomOverlay = ({
const effectiveDisplay = activeDisplay; const effectiveDisplay = activeDisplay;
const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext); const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext);
const stageClassName = [ const stageClassName = 'preview-zoom__stage';
'preview-zoom__stage',
]
.filter(Boolean)
.join(' ');
const containerClassName = [ const containerClassName = [
'preview-zoom__scroll', 'preview-zoom__scroll',
@@ -252,7 +269,7 @@ const PreviewZoomOverlay = ({
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
const imageStyle = isNativeScale const imageStyle: CSSProperties = isNativeScale
? { ? {
cursor: 'zoom-out', cursor: 'zoom-out',
width: naturalSize.width ? `${naturalSize.width}px` : 'auto', width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
@@ -277,37 +294,34 @@ const PreviewZoomOverlay = ({
aria-label="Enlarged document preview" aria-label="Enlarged document preview"
onClick={onClose} onClick={onClose}
> >
<div <div className={stageClassName} onKeyDown={handleKeyDown}>
className={stageClassName} <div
onKeyDown={handleKeyDown} className={containerClassName}
> ref={scrollRef}
<div tabIndex={-1}
className={containerClassName} onClick={(event) => event.stopPropagation()}
ref={scrollRef} >
tabIndex={-1} <img
onClick={(event) => event.stopPropagation()} src={effectiveDisplay.url}
> alt={effectiveDisplay.alt || 'Document preview'}
<img className="preview-zoom__image"
src={effectiveDisplay.url} ref={imageRef}
alt={effectiveDisplay.alt || 'Document preview'} draggable={false}
className="preview-zoom__image" onLoad={(event) => {
ref={imageRef} setNaturalSize({
draggable={false} width: event.currentTarget.naturalWidth || null,
onLoad={(event) => { height: event.currentTarget.naturalHeight || null,
setNaturalSize({ });
width: event.currentTarget.naturalWidth || null, }}
height: event.currentTarget.naturalHeight || null, onClick={handleImageClick}
}); style={imageStyle}
}} />
onClick={handleImageClick} </div>
style={imageStyle} {navVisible ? (
/> <div className="preview-zoom__nav">
</div> <button
{navVisible ? ( type="button"
<div className="preview-zoom__nav"> className="preview-zoom__nav-button"
<button
type="button"
className="preview-zoom__nav-button"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) { if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) {
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import type { MutableRefObject } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager'; import { resolveDocumentAssetUrl } from '../asset_manager';
import { useDetailPanel } from '../app/useDetailPanel'; import { useDetailPanel } from '../app/useDetailPanel';
import { import {
@@ -6,6 +7,75 @@ import {
getRowId, getRowId,
isDocumentRowKey, isDocumentRowKey,
} from '../app/appLayoutUtils'; } 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 = ({ const useDetailWorkspace = ({
documents, documents,
@@ -36,7 +106,7 @@ const useDetailWorkspace = ({
selectFolder, selectFolder,
tags, tags,
tagLookupById, tagLookupById,
}) => { }: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => {
const orderedSelectedDocuments = useMemo(() => { const orderedSelectedDocuments = useMemo(() => {
const ordered = []; const ordered = [];
const seen = new Set(); const seen = new Set();
@@ -2,7 +2,19 @@ import React from 'react';
const NBSP = String.fromCharCode(160); 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, correspondents,
activeCorrespondentIdSet, activeCorrespondentIdSet,
onCorrespondentClick, onCorrespondentClick,
@@ -11,8 +23,8 @@ const CorrespondentLinks = ({
return null; return null;
} }
const activeSet = activeCorrespondentIdSet || new Set(); const activeSet = activeCorrespondentIdSet || new Set<string | number>();
const handleClick = (event, correspondent) => { const handleClick = (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>, correspondent: CorrespondentLinkEntry) => {
if (!onCorrespondentClick || correspondent.id == null) { if (!onCorrespondentClick || correspondent.id == null) {
return; return;
} }
@@ -27,11 +39,12 @@ const CorrespondentLinks = ({
if (isActive) classNames.push('is-active'); if (isActive) classNames.push('is-active');
if (!hasHandler) classNames.push('is-static'); if (!hasHandler) classNames.push('is-static');
const isLast = index === correspondents.length - 1; 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 ( return (
<React.Fragment <React.Fragment
key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`} key={correspondent.key ?? correspondent.id ?? `${fallbackLabel}-${index}`}
> >
<button <button
type="button" type="button"
@@ -1,8 +1,54 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; 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'; 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, document,
summaryProps = {}, summaryProps = {},
metadataItems: metadataItemsProp, metadataItems: metadataItemsProp,
@@ -42,9 +88,9 @@ const DocumentInfoPanel = ({
const contentConfig = contentConfigProp || null; const contentConfig = contentConfigProp || null;
const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true)); 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) { if (!contentConfig) {
return null; return null;
} }
@@ -144,14 +190,14 @@ const DocumentInfoPanel = ({
const normalizedLeadingTabs = useMemo( const normalizedLeadingTabs = useMemo(
() => (Array.isArray(leadingTabs) () => (Array.isArray(leadingTabs)
? leadingTabs.filter((tab) => tab && tab.id && tab.label) ? leadingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
: []), : []),
[leadingTabs], [leadingTabs],
); );
const normalizedTrailingTabs = useMemo( const normalizedTrailingTabs = useMemo(
() => (Array.isArray(trailingTabs) () => (Array.isArray(trailingTabs)
? trailingTabs.filter((tab) => tab && tab.id && tab.label) ? trailingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
: []), : []),
[trailingTabs], [trailingTabs],
); );
@@ -166,7 +212,7 @@ const DocumentInfoPanel = ({
: null; : null;
const visibleTabs = useMemo(() => { const visibleTabs = useMemo(() => {
const tabsList = []; const tabsList: PanelTab[] = [];
if (normalizedLeadingTabs.length) { if (normalizedLeadingTabs.length) {
tabsList.push(...normalizedLeadingTabs); tabsList.push(...normalizedLeadingTabs);
@@ -251,17 +297,17 @@ const DocumentInfoPanel = ({
} }
if (metadataPayload) { if (metadataPayload) {
tabsList.push({ tabsList.push({
id: 'metadata', id: 'metadata',
label: metadataTabLabel, label: metadataTabLabel,
render: () => ( render: () => (
<section className={`${base}__section ${base}__section--metadata-json`}> <section className={`${base}__section ${base}__section--metadata-json`}>
<pre className={`${base}__metadata-json`}> <pre className={`${base}__metadata-json`}>
{JSON.stringify(metadataPayload, null, 2)} {JSON.stringify(metadataPayload, null, 2)}
</pre> </pre>
</section> </section>
), ),
}); });
} }
if (normalizedTrailingTabs.length) { if (normalizedTrailingTabs.length) {
@@ -295,18 +341,14 @@ const DocumentInfoPanel = ({
return visibleTabs[0].id; return visibleTabs[0].id;
}, [visibleTabs, defaultTabId]); }, [visibleTabs, defaultTabId]);
const renderTabContent = (tab, context = {}) => { const renderTabContent = (tab?: PanelTab | null, context: Record<string, unknown> = {}) => {
if (!tab) { if (!tab) {
return null; return null;
} }
if (typeof tab.render === 'function') { if (typeof tab.render === 'function') {
return tab.render(context); return tab.render(context);
} }
if (tab.component) { return null;
const TabComponent = tab.component;
return <TabComponent {...context} />;
}
return React.isValidElement(tab.render) ? tab.render : null;
}; };
const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null; const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null;
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { EditIcon, IconX, PlusIcon } from '../ui/icons'; import { EditIcon, IconX, PlusIcon } from '../ui/icons';
import SelectionAssignmentMenu from './SelectionAssignmentMenu'; import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { import {
formatDate, formatDate,
@@ -9,6 +10,68 @@ import {
} from '../utils/date'; } from '../utils/date';
import { describeDocumentSummary } from './documentSummary'; 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 = []) => export const sortCorrespondents = (entries = []) =>
entries entries
.filter((entry) => entry && entry.name) .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) { if (option == null) {
return null; return null;
} }
const isObject = typeof option === 'object'; const isObject = typeof option === 'object';
const labelSource = isObject ? option.label ?? option.name ?? '' : option; const labelSource = isObject ? option.label ?? option.name ?? '' : option;
const label = labelSource?.trim?.() || ''; const label = typeof labelSource === 'string' ? labelSource.trim() : '';
if (!label) { if (!label) {
return null; return null;
} }
@@ -51,7 +127,7 @@ const normalizeQuickAddOption = (option) => {
}; };
}; };
export const TagSection = ({ export const TagSection: React.FC<TagSectionProps> = ({
tags = [], tags = [],
onRemove, onRemove,
onAdd, onAdd,
@@ -62,17 +138,17 @@ export const TagSection = ({
className, className,
}) => { }) => {
const handleCreate = useCallback( const handleCreate = useCallback(
(label) => onAdd?.({ value: label, input: null }), (label: string) => onAdd?.({ value: label, input: null }),
[onAdd], [onAdd],
); );
const handleSelect = useCallback( const handleSelect = useCallback(
(option) => { (option: { label?: string; name?: string } | string | null) => {
if (!onAdd) return; if (!onAdd) return;
const labelSource = option && typeof option === 'object' const labelSource = option && typeof option === 'object'
? option.label ?? option.name ?? '' ? option.label ?? option.name ?? ''
: option; : option;
const label = labelSource?.trim?.() || ''; const label = typeof labelSource === 'string' ? labelSource.trim() : '';
if (!label) { if (!label) {
return; return;
} }
@@ -85,14 +161,14 @@ export const TagSection = ({
() => () =>
normalizeOptions(datalistOptions) normalizeOptions(datalistOptions)
.map((option) => normalizeQuickAddOption(option)) .map((option) => normalizeQuickAddOption(option))
.filter(Boolean), .filter((option): option is QuickAddEntry => Boolean(option)),
[datalistOptions], [datalistOptions],
); );
const containerClass = className ? `tag-list ${className}` : 'tag-list'; const containerClass = className ? `tag-list ${className}` : 'tag-list';
const showQuickAdd = Boolean(onAdd); const showQuickAdd = Boolean(onAdd);
const assignmentItems = useMemo(() => { const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
const map = new Map(); const map = new Map<string, SelectionAssignmentMenuItem>();
normalizedOptions.forEach((option) => { normalizedOptions.forEach((option) => {
const label = option?.label?.trim(); const label = option?.label?.trim();
@@ -120,8 +196,10 @@ export const TagSection = ({
const payload = { id: tag.id, label, color: tag.color ?? null }; const payload = { id: tag.id, label, color: tag.color ?? null };
if (map.has(key)) { if (map.has(key)) {
const entry = map.get(key); const entry = map.get(key);
entry.state = 'all'; if (entry) {
entry.payload = payload; entry.state = 'all';
entry.payload = payload;
}
return; return;
} }
map.set(key, { map.set(key, {
@@ -136,7 +214,7 @@ export const TagSection = ({
}, [normalizedOptions, tags]); }, [normalizedOptions, tags]);
const handleAssignmentSelect = useCallback( const handleAssignmentSelect = useCallback(
(item) => { (item: SelectionAssignmentMenuItem | null) => {
if (!item) { if (!item) {
return; return;
} }
@@ -192,7 +270,7 @@ export const TagSection = ({
); );
}; };
export const CorrespondentSection = ({ export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
entries = [], entries = [],
onRemove, onRemove,
onAdd, onAdd,
@@ -203,7 +281,7 @@ export const CorrespondentSection = ({
className, className,
}) => { }) => {
const handleCreate = useCallback( const handleCreate = useCallback(
(name) => onAdd?.({ name, input: null }), (name: string) => onAdd?.({ name, input: null }),
[onAdd], [onAdd],
); );
@@ -211,15 +289,15 @@ export const CorrespondentSection = ({
() => () =>
normalizeOptions(datalistOptions) normalizeOptions(datalistOptions)
.map((option) => normalizeQuickAddOption(option)) .map((option) => normalizeQuickAddOption(option))
.filter(Boolean), .filter((option): option is QuickAddEntry => Boolean(option)),
[datalistOptions], [datalistOptions],
); );
const hasEntries = entries && entries.length > 0; const hasEntries = entries && entries.length > 0;
const showQuickAdd = Boolean(onAdd); const showQuickAdd = Boolean(onAdd);
const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list'; const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
const assignmentItems = useMemo(() => { const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
const map = new Map(); const map = new Map<string, SelectionAssignmentMenuItem>();
normalizedOptions.forEach((option) => { normalizedOptions.forEach((option) => {
const label = option?.label?.trim(); const label = option?.label?.trim();
@@ -247,8 +325,10 @@ export const CorrespondentSection = ({
const payload = { id: entry.id, name: label }; const payload = { id: entry.id, name: label };
if (map.has(key)) { if (map.has(key)) {
const item = map.get(key); const item = map.get(key);
item.state = 'all'; if (item) {
item.payload = payload; item.state = 'all';
item.payload = payload;
}
return; return;
} }
map.set(key, { map.set(key, {
@@ -263,7 +343,7 @@ export const CorrespondentSection = ({
}, [normalizedOptions, entries]); }, [normalizedOptions, entries]);
const handleAssignmentSelect = useCallback( const handleAssignmentSelect = useCallback(
(item) => { (item: SelectionAssignmentMenuItem | null) => {
if (!onAdd || !item) { if (!onAdd || !item) {
return; return;
} }
@@ -333,7 +413,7 @@ export const CorrespondentSection = ({
); );
}; };
const DocumentSummarySection = ({ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
document, document,
tagLookupById = new Map(), tagLookupById = new Map(),
tagOptions = [], tagOptions = [],
@@ -387,7 +467,7 @@ const DocumentSummarySection = ({
}, [correspondents, document?.correspondents]); }, [correspondents, document?.correspondents]);
const metaRows = useMemo(() => { const metaRows = useMemo(() => {
const rows = []; const rows: { key: string; label: string; value: string | null | undefined }[] = [];
const currentVersionNumber = document?.current_version?.version_number; const currentVersionNumber = document?.current_version?.version_number;
if (Number.isFinite(currentVersionNumber)) { if (Number.isFinite(currentVersionNumber)) {
rows.push({ rows.push({
@@ -433,7 +513,7 @@ const DocumentSummarySection = ({
const startTitleEdit = useCallback(() => { const startTitleEdit = useCallback(() => {
if (!editableTitle || !document) return; if (!editableTitle || !document) return;
setIsTitleEditing(true); setIsTitleEditing(true);
setTitleDraft(document.title); setTitleDraft(document.title || '');
setTitleError(null); setTitleError(null);
}, [document, editableTitle]); }, [document, editableTitle]);
@@ -445,9 +525,9 @@ const DocumentSummarySection = ({
}, []); }, []);
const submitTitleEdit = useCallback( const submitTitleEdit = useCallback(
async (event) => { async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (!editableTitle || !document) return; if (!editableTitle || !document || !onUpdateTitle) return;
const trimmed = titleDraft.trim(); const trimmed = titleDraft.trim();
if (!trimmed) { if (!trimmed) {
setTitleError('Title cannot be empty.'); setTitleError('Title cannot be empty.');
@@ -483,9 +563,9 @@ const DocumentSummarySection = ({
}, []); }, []);
const submitIssuedEdit = useCallback( const submitIssuedEdit = useCallback(
async (event) => { async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (!editableIssued || !document) return; if (!editableIssued || !document || !onUpdateIssued) return;
const normalizedValue = issuedDraft ? toIssuedTimestamp(issuedDraft, document.issued_at) : null; const normalizedValue = issuedDraft ? toIssuedTimestamp(issuedDraft, document.issued_at) : null;
setIssuedSaving(true); setIssuedSaving(true);
try { try {
@@ -749,15 +829,16 @@ const DocumentSummarySection = ({
className="icon-button" className="icon-button"
onClick={startTitleEdit} onClick={startTitleEdit}
aria-label="Edit title" aria-label="Edit title"
title="Edit title" title="Edit title"
> >
<EditIcon className="icon-inline" /> <EditIcon className="icon-inline" />
</button> </button>
) : null} ) : null}
</> </>
)} )}
</div>
</div> </div>
</div>
{titleError ? <div className="status-inline error">{titleError}</div> : null} {titleError ? <div className="status-inline error">{titleError}</div> : null}
<div className="detail-meta"> <div className="detail-meta">
@@ -776,7 +857,6 @@ const DocumentSummarySection = ({
</div> </div>
{renderTags()} {renderTags()}
{renderCorrespondents()} {renderCorrespondents()}
</div> </div>
); );
@@ -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'; import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
const DEFAULT_THUMBNAIL_SIZE = 48; const DEFAULT_THUMBNAIL_SIZE = 48;
// Detect when an element becomes visible within a scroll container so we can delay loading. // Detect when an element becomes visible within a scroll container so we can delay loading.
const useLazyVisibility = (rootRef, resetKey) => { const useLazyVisibility = (
const targetRef = useRef(null); rootRef: MutableRefObject<Element | null> | null,
resetKey: string | number | null | undefined,
) => {
const targetRef = useRef<HTMLDivElement | null>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
useEffect(() => { useEffect(() => {
@@ -24,7 +28,7 @@ const useLazyVisibility = (rootRef, resetKey) => {
return undefined; return undefined;
} }
if (!('IntersectionObserver' in window)) { if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
setIsVisible(true); setIsVisible(true);
return undefined; return undefined;
} }
@@ -52,11 +56,54 @@ const useLazyVisibility = (rootRef, resetKey) => {
return { ref: targetRef, isVisible }; return { ref: targetRef, isVisible };
}; };
const getPageCount = (doc) => const getPageCount = (doc: DocumentLike | null | undefined) =>
Number.isFinite(doc?.current_version?.metadata?.page_count) Number.isFinite(doc?.current_version?.metadata?.page_count)
? doc.current_version.metadata.page_count ? (doc?.current_version?.metadata?.page_count as number)
: null; : 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 = ({ const DocumentThumbnailImage = ({
document, document,
ensureAssetUrl, ensureAssetUrl,
@@ -64,12 +111,12 @@ const DocumentThumbnailImage = ({
alt = '', alt = '',
maxSize = DEFAULT_THUMBNAIL_SIZE, maxSize = DEFAULT_THUMBNAIL_SIZE,
scrollRootRef = null, scrollRootRef = null,
}) => { }: DocumentThumbnailImageProps): JSX.Element => {
const documentId = document?.id; const documentId = document?.id;
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId); const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId);
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1)); const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
const thumbnailAsset = useMemo( const thumbnailAsset = useMemo<AssetLike | null>(
() => getAssetFromVersion(document?.current_version, 'thumbnail'), () => getAssetFromVersion(document?.current_version, 'thumbnail'),
[document?.current_version], [document?.current_version],
); );
@@ -89,7 +136,7 @@ const DocumentThumbnailImage = ({
}; };
}, [assetWidth, assetHeight, resolvedMaxSize]); }, [assetWidth, assetHeight, resolvedMaxSize]);
const innerStyle = useMemo( const innerStyle = useMemo<CSSProperties>(
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }), () => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
[dimensions.height, dimensions.width], [dimensions.height, dimensions.width],
); );
@@ -98,10 +145,17 @@ const DocumentThumbnailImage = ({
if (!isVisible) { if (!isVisible) {
return null; return null;
} }
return resolveDocumentAssetUrl(document, 'thumbnail', { const options: {
ensureAssetUrl, ensureAssetUrl?: EnsureAssetUrl;
getAsset: getDocumentAsset, getAsset?: GetDocumentAsset;
}); } = {};
if (ensureAssetUrl) {
options.ensureAssetUrl = ensureAssetUrl;
}
if (getDocumentAsset) {
options.getAsset = getDocumentAsset;
}
return resolveDocumentAssetUrl(document, 'thumbnail', options);
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]); }, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
const pageCount = getPageCount(document); const pageCount = getPageCount(document);
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import type { DragEvent, MouseEvent, RefObject } from 'react';
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons'; import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
import DocumentThumbnailImage from './DocumentThumbnailImage'; import DocumentThumbnailImage from './DocumentThumbnailImage';
import CorrespondentLinks from './CorrespondentLinks'; import CorrespondentLinks from './CorrespondentLinks';
@@ -7,7 +8,85 @@ import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer'; import { writeTagTransferData } from './tagTransfer';
import useInlineRename from './useInlineRename'; 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, entries,
selectedDocumentIdsSet, selectedDocumentIdsSet,
selectedFolderIdsSet, selectedFolderIdsSet,
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import type { DragEvent, MouseEvent, RefObject } from 'react';
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons'; import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { formatDate } from '../utils/date'; import { formatDate } from '../utils/date';
@@ -8,7 +9,89 @@ import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer'; import { writeTagTransferData } from './tagTransfer';
import useInlineRename from './useInlineRename'; 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, entries,
focusedRowKey, focusedRowKey,
selectedDocumentIdsSet, selectedDocumentIdsSet,
@@ -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 useFloatingMenu from '../ui/useFloatingMenu';
import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../ui/icons'; 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, all: 0,
partial: 1, partial: 1,
none: 2, none: 2,
}; };
const normalizeItems = (items) => const normalizeItems = (items?: SelectionAssignmentMenuItem[]): NormalizedSelectionAssignmentItem[] =>
(Array.isArray(items) ? items : []) (Array.isArray(items) ? items : [])
.map((item) => { .map<NormalizedSelectionAssignmentItem | null>((item) => {
if (!item) { if (!item) {
return null; return null;
} }
@@ -18,18 +59,23 @@ const normalizeItems = (items) =>
if (!trimmedLabel) { if (!trimmedLabel) {
return null; return null;
} }
const state: AssignmentState = item.state === 'all'
? 'all'
: item.state === 'partial'
? 'partial'
: 'none';
return { return {
id: item.id ?? trimmedLabel, id: item.id ?? trimmedLabel,
label: trimmedLabel, label: trimmedLabel,
state: item.state === 'all' ? 'all' : item.state === 'partial' ? 'partial' : 'none', state,
count: typeof item.count === 'number' ? item.count : null, count: typeof item.count === 'number' ? item.count : null,
total: typeof item.total === 'number' ? item.total : null, total: typeof item.total === 'number' ? item.total : null,
payload: item.payload ?? item, payload: item.payload ?? item,
}; };
}) })
.filter(Boolean); .filter((item): item is NormalizedSelectionAssignmentItem => Boolean(item));
const SelectionAssignmentMenu = ({ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
label, label,
items = [], items = [],
placeholder = 'Search…', placeholder = 'Search…',
@@ -47,8 +93,8 @@ const SelectionAssignmentMenu = ({
renderItemLabel = null, renderItemLabel = null,
positionStrategy = 'absolute', positionStrategy = 'absolute',
}) => { }) => {
const anchorRef = useRef(null); const anchorRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
@@ -64,7 +110,14 @@ const SelectionAssignmentMenu = ({
align: 'center', align: 'center',
positionStrategy, positionStrategy,
minWidth: 220, minWidth: 220,
}); }) as {
isOpen: boolean;
toggle: () => void;
close: () => void;
menuRef: React.MutableRefObject<HTMLDivElement | null>;
menuStyle: CSSProperties | null;
updatePosition: () => void;
};
useEffect(() => { useEffect(() => {
if (disabled && isOpen) { if (disabled && isOpen) {
@@ -106,7 +159,7 @@ const SelectionAssignmentMenu = ({
}, [normalizedItems, query]); }, [normalizedItems, query]);
const handleToggle = useCallback( const handleToggle = useCallback(
async (item) => { async (item: NormalizedSelectionAssignmentItem) => {
if (!item || typeof onToggle !== 'function') { if (!item || typeof onToggle !== 'function') {
return; return;
} }
@@ -124,7 +177,7 @@ const SelectionAssignmentMenu = ({
); );
const handleCreate = useCallback( const handleCreate = useCallback(
async (event) => { async (event: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault?.(); event?.preventDefault?.();
if (typeof onCreate !== 'function') { if (typeof onCreate !== 'function') {
return; return;
@@ -1,10 +1,4 @@
import React, { import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { import {
TrashIcon, TrashIcon,
AnalyzeIcon, AnalyzeIcon,
@@ -14,19 +8,90 @@ import {
CorrespondentIcon, CorrespondentIcon,
LoaderIcon, LoaderIcon,
} from '../ui/icons'; } from '../ui/icons';
import SelectionAssignmentMenu from './SelectionAssignmentMenu'; import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
import SelectionSummary from './SelectionSummary'; import SelectionSummary from './SelectionSummary';
import { api, useAppState } from '../app/appState'; import { api, useAppState } from '../app/appState';
const normalizeDocumentList = (selectedDocumentIds) =>
Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : [];
const ROOT_FOLDER_LABEL = 'Documents'; const ROOT_FOLDER_LABEL = 'Documents';
const buildFolderTreeOptions = (tree) => { type DocumentId = string | number;
const entries = []; 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) { if (!Array.isArray(nodes) || nodes.length === 0) {
return; return;
} }
@@ -34,11 +99,21 @@ const buildFolderTreeOptions = (tree) => {
if (!node || !node.id) { if (!node || !node.id) {
return; return;
} }
const trimmedName = node?.name?.trim?.(); const trimmedName = node.name?.trim?.();
const name = trimmedName?.length ? trimmedName : 'Folder'; const name = trimmedName?.length ? trimmedName : 'Folder';
const nextSegments = parentSegments.concat([name]); const nextSegments = parentSegments.concat([name]);
const label = nextSegments.join('/'); 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) { if (Array.isArray(node.children) && node.children.length) {
traverse(node.children, nextSegments); traverse(node.children, nextSegments);
} }
@@ -47,19 +122,30 @@ const buildFolderTreeOptions = (tree) => {
traverse(Array.isArray(tree) ? tree : [], [ROOT_FOLDER_LABEL]); 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) { if (!total) {
return []; 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; const key = id ?? label;
if (!key || !label) { if (!key || !label) {
return null; return null;
@@ -73,7 +159,7 @@ const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
total, total,
}); });
} }
return map.get(key); return map.get(key) ?? null;
}; };
selectedDocuments.forEach((doc) => { 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) { if (!total) {
return []; 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; const key = id ?? name;
if (!key || !name) { if (!key || !name) {
return null; return null;
@@ -126,7 +221,7 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total)
total, total,
}); });
} }
return map.get(key); return map.get(key) ?? null;
}; };
selectedDocuments.forEach((doc) => { selectedDocuments.forEach((doc) => {
@@ -139,7 +234,7 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total)
}); });
(correspondents || []).forEach((entry) => { (correspondents || []).forEach((entry) => {
ensureEntry(entry?.id, entry?.name); ensureEntry(entry?.id, entry?.name || entry?.label);
}); });
return Array.from(map.values()).map((entry) => { 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, selectionCount = 0,
selectedDocumentIds, selectedDocumentIds = [],
selectedFolderIds = [], selectedFolderIds = [],
documentLookup, documentLookup,
tags, tags = [],
tagLookupById, tagLookupById,
correspondents, correspondents = [],
folderOptions = [], folderOptions = [],
onBulkTagAdd, onBulkTagAdd,
onBulkTagRemove, onBulkTagRemove,
@@ -174,12 +269,17 @@ const SelectionFloatingActions = ({
onClearSelection = null, onClearSelection = null,
onMoveDocumentsToFolder, onMoveDocumentsToFolder,
}) => { }) => {
const { token, tenant } = useAppState(); const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId } | null };
const tenantId = tenant?.id ?? 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 [loadingFolders, setLoadingFolders] = useState(false);
const folderTreeFetchRef = useRef(null); const folderTreeFetchRef = useRef<Promise<SelectionAssignmentMenuItem[]> | null>(null);
useEffect(() => { useEffect(() => {
setRemoteFolderOptions(null); setRemoteFolderOptions(null);
@@ -187,7 +287,7 @@ const SelectionFloatingActions = ({
setLoadingFolders(false); setLoadingFolders(false);
}, [tenantId, token]); }, [tenantId, token]);
const requestFolderTree = useCallback(async () => { const requestFolderTree = useCallback(async (): Promise<SelectionAssignmentMenuItem[]> => {
if (!token) { if (!token) {
setRemoteFolderOptions([]); setRemoteFolderOptions([]);
return []; return [];
@@ -226,59 +326,55 @@ const SelectionFloatingActions = ({
requestFolderTree(); requestFolderTree();
}, [requestFolderTree]); }, [requestFolderTree]);
const effectiveFolderOptions = useMemo(() => { const effectiveFolderOptions = useMemo<SelectionAssignmentMenuItem[]>(() => {
if (remoteFolderOptions !== null) { if (remoteFolderOptions !== null) {
return remoteFolderOptions; return remoteFolderOptions;
} }
return Array.isArray(folderOptions) ? folderOptions : []; return Array.isArray(folderOptions) ? folderOptions : [];
}, [remoteFolderOptions, folderOptions]); }, [remoteFolderOptions, folderOptions]);
const documentIdList = useMemo( const documentIdList = useMemo<DocumentId[]>(
() => normalizeDocumentList(selectedDocumentIds), () => normalizeDocumentList(selectedDocumentIds),
[selectedDocumentIds], [selectedDocumentIds],
); );
const folderIdList = useMemo( const folderIdList = useMemo<DocumentId[]>(
() => normalizeDocumentList(selectedFolderIds), () => normalizeDocumentList(selectedFolderIds),
[selectedFolderIds], [selectedFolderIds],
); );
const documentCount = documentIdList.length; const documentCount = documentIdList.length;
const folderCount = folderIdList.length; const folderCount = folderIdList.length;
const totalCount = typeof selectionCount === 'number' const totalCount = typeof selectionCount === 'number' ? selectionCount : documentCount + folderCount;
? selectionCount
: documentCount + folderCount;
const selectedDocuments = useMemo(() => { const selectedDocuments = useMemo<DocumentLike[]>(() => {
if (!documentIdList.length || !(documentLookup instanceof Map)) { if (!documentIdList.length || !(documentLookupMap instanceof Map)) {
return []; return [];
} }
return documentIdList return documentIdList
.map((id) => documentLookup.get(id)) .map((id) => documentLookupMap.get(id))
.filter(Boolean); .filter((doc): doc is DocumentLike => Boolean(doc));
}, [documentIdList, documentLookup]); }, [documentIdList, documentLookupMap]);
const selectedDocCount = selectedDocuments.length; const selectedDocCount = selectedDocuments.length;
const moveAssignments = useMemo(() => { const moveAssignments = useMemo<SelectionAssignmentMenuItem[]>(() => {
if (!Array.isArray(effectiveFolderOptions)) { if (!Array.isArray(effectiveFolderOptions)) {
return []; return [];
} }
return effectiveFolderOptions return effectiveFolderOptions
.map((option) => { .map<SelectionAssignmentMenuItem | null>((option) => {
const id = option?.id ?? option?.value ?? option; const id = (option?.id ?? option?.payload?.id ?? option?.value) as DocumentId | undefined;
if (!id) { if (!id) {
return null; return null;
} }
const label = option?.label || option?.name || String(id); const label = option?.label || option?.payload?.label || option?.name || String(id);
const segments = label.split('/'); const segments = typeof label === 'string' ? label.split('/') : [label];
const depth = Math.max(segments.length - 1, 0); const depth = Math.max(segments.length - 1, 0);
return { return {
id, id,
label, label,
state: 'none', state: 'none',
count: null,
total: null,
payload: { payload: {
id, id,
label, label,
@@ -287,12 +383,12 @@ const SelectionFloatingActions = ({
}, },
}; };
}) })
.filter(Boolean); .filter((entry): entry is SelectionAssignmentMenuItem => Boolean(entry));
}, [effectiveFolderOptions]); }, [effectiveFolderOptions]);
const tagAssignments = useMemo( const tagAssignments = useMemo(
() => buildTagAssignments(selectedDocuments, tagLookupById, tags, selectedDocCount), () => buildTagAssignments(selectedDocuments, tagLookupMap, tags, selectedDocCount),
[selectedDocuments, tagLookupById, tags, selectedDocCount], [selectedDocuments, tagLookupMap, tags, selectedDocCount],
); );
const correspondentAssignments = useMemo( const correspondentAssignments = useMemo(
@@ -300,9 +396,10 @@ const SelectionFloatingActions = ({
[selectedDocuments, correspondents, selectedDocCount], [selectedDocuments, correspondents, selectedDocCount],
); );
const renderFolderLabel = useCallback((item) => { const renderFolderLabel = useCallback((item: SelectionAssignmentMenuItem) => {
const segments = item?.payload?.segments || (item?.label ? item.label.split('/') : []); const payload = (item?.payload as { segments?: string[]; depth?: number }) || {};
const depth = item?.payload?.depth ?? Math.max(segments.length - 1, 0); 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 clampedDepth = Math.min(depth, 6);
const indentWidth = clampedDepth > 0 ? clampedDepth * 0.9 : 0; const indentWidth = clampedDepth > 0 ? clampedDepth * 0.9 : 0;
const name = segments.length ? segments[segments.length - 1] : item?.label || 'Folder'; const name = segments.length ? segments[segments.length - 1] : item?.label || 'Folder';
@@ -328,21 +425,21 @@ const SelectionFloatingActions = ({
}, []); }, []);
const handleToggleTagAssignment = useCallback( const handleToggleTagAssignment = useCallback(
async (item) => { async (item: SelectionAssignmentMenuItem) => {
if (!selectedDocCount || !item) { if (!selectedDocCount || !item) {
return; return;
} }
if (item.state === 'all') { if (item.state === 'all') {
await onBulkTagRemove?.({ label: item.label, input: null, documentIds: documentIdList }); await onBulkTagRemove?.({ label: item.label || '', input: null, documentIds: documentIdList });
} else { } else {
await onBulkTagAdd?.({ label: item.label, input: null, documentIds: documentIdList }); await onBulkTagAdd?.({ label: item.label || '', input: null, documentIds: documentIdList });
} }
}, },
[selectedDocCount, onBulkTagAdd, onBulkTagRemove, documentIdList], [selectedDocCount, onBulkTagAdd, onBulkTagRemove, documentIdList],
); );
const handleCreateTagAssignment = useCallback( const handleCreateTagAssignment = useCallback(
async (label) => { async (label: string) => {
if (!selectedDocCount || !label) { if (!selectedDocCount || !label) {
return; return;
} }
@@ -352,7 +449,7 @@ const SelectionFloatingActions = ({
); );
const handleToggleCorrespondentAssignment = useCallback( const handleToggleCorrespondentAssignment = useCallback(
async (item) => { async (item: SelectionAssignmentMenuItem) => {
if (!selectedDocCount || !item) { if (!selectedDocCount || !item) {
return; return;
} }
@@ -365,14 +462,14 @@ const SelectionFloatingActions = ({
documentIds: documentIdList, documentIds: documentIdList,
}); });
} else { } else {
await onBulkCorrespondentAdd?.({ name: item.label, input: null, documentIds: documentIdList }); await onBulkCorrespondentAdd?.({ name: item.label || '', input: null, documentIds: documentIdList });
} }
}, },
[selectedDocCount, onBulkCorrespondentAdd, onBulkCorrespondentRemove, documentIdList], [selectedDocCount, onBulkCorrespondentAdd, onBulkCorrespondentRemove, documentIdList],
); );
const handleCreateCorrespondentAssignment = useCallback( const handleCreateCorrespondentAssignment = useCallback(
async (name) => { async (name: string) => {
if (!selectedDocCount || !name) { if (!selectedDocCount || !name) {
return; return;
} }
@@ -382,15 +479,18 @@ const SelectionFloatingActions = ({
); );
const handleMoveSelectionToFolder = useCallback( const handleMoveSelectionToFolder = useCallback(
async (option) => { async (option: unknown) => {
if (!documentIdList.length || typeof onMoveDocumentsToFolder !== 'function') { if (!documentIdList.length || typeof onMoveDocumentsToFolder !== 'function') {
return; return;
} }
const value = option?.id ?? option?.value ?? option; const candidate = option as { id?: DocumentId; value?: DocumentId } | DocumentId | null | undefined;
if (!value) { const value = typeof candidate === 'object'
? candidate?.id ?? candidate?.value ?? null
: candidate;
if (!value && value !== 0) {
return; return;
} }
await onMoveDocumentsToFolder(documentIdList, value); await onMoveDocumentsToFolder(documentIdList, value as DocumentId);
}, },
[documentIdList, onMoveDocumentsToFolder], [documentIdList, onMoveDocumentsToFolder],
); );
@@ -1,7 +1,13 @@
import React from 'react'; import React from 'react';
import { FileIcon, FolderOutlineIcon } from '../ui/icons'; 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 docCount = Number(documentCount) || 0;
const folderCountNumber = Number(folderCount) || 0; const folderCountNumber = Number(folderCount) || 0;
const aggregateCount = docCount + folderCountNumber; const aggregateCount = docCount + folderCountNumber;
-34
View File
@@ -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;
+50
View File
@@ -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;
@@ -1,9 +1,23 @@
import { openOcrTextInNewTab } from '../utils/ocr'; 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 asyncFalse = async () => false;
const resolveDocumentDownloadHref = (document, resolveApiPath) => { const resolveDocumentDownloadHref = (document: DocumentLike | null | undefined, resolveApiPath?: ResolveApiPath | null): string | null => {
if (!document || typeof resolveApiPath !== 'function') { if (!document || !resolveApiPath) {
return null; return null;
} }
const downloadPath = document.current_version?.download_path; const downloadPath = document.current_version?.download_path;
@@ -13,13 +27,23 @@ const resolveDocumentDownloadHref = (document, resolveApiPath) => {
return resolveApiPath(downloadPath); return resolveApiPath(downloadPath);
}; };
const hasDocumentOcrAsset = (document, getDocumentAsset) => { const hasDocumentOcrAsset = (document: DocumentLike | null | undefined, getDocumentAsset?: GetDocumentAsset | null): boolean => {
if (!document || typeof getDocumentAsset !== 'function') { if (!document || !getDocumentAsset) {
return false; return false;
} }
return Boolean(getDocumentAsset(document, 'ocr-text')); 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 = ({ export const createDocumentActionState = ({
document, document,
resolveApiPath, resolveApiPath,
@@ -28,7 +52,7 @@ export const createDocumentActionState = ({
getDocumentAsset, getDocumentAsset,
notifyApiError, notifyApiError,
ocrErrorMessage = 'Unable to open OCR text.', ocrErrorMessage = 'Unable to open OCR text.',
}) => { }: CreateDocumentActionStateArgs) => {
if (!document) { if (!document) {
return { return {
downloadHref: null, downloadHref: null,
@@ -49,14 +73,12 @@ export const createDocumentActionState = ({
getDocumentAsset, getDocumentAsset,
ensureAssetUrl, ensureAssetUrl,
}); });
if (!success && typeof notifyApiError === 'function') { if (!success) {
notifyApiError(new Error('OCR text URL unavailable.'), ocrErrorMessage); notifyApiError?.(new Error('OCR text URL unavailable.'), ocrErrorMessage);
} }
return success; return success;
} catch (error) { } catch (error) {
if (typeof notifyApiError === 'function') { notifyApiError?.(error, ocrErrorMessage);
notifyApiError(error, ocrErrorMessage);
}
throw error; throw error;
} }
} }
@@ -1,6 +1,29 @@
import { formatDateTime } from '../utils/date'; 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) { if (!document) {
return []; return [];
} }
@@ -29,8 +52,8 @@ export const buildDocumentMetadataItems = (document) => {
]; ];
}; };
export const extractDocumentMetadataPayload = (document) => { export const extractDocumentMetadataPayload = (document?: DocumentLike | null): DocumentMetadata | null => {
if (!document || !document.metadata) { if (!document?.metadata) {
return null; return null;
} }
const keys = Object.keys(document.metadata); const keys = Object.keys(document.metadata);
@@ -1,13 +1,71 @@
import { formatFileSize } from '../utils/format'; import { formatFileSize } from '../utils/format';
import { formatDateTime as defaultFormatDateTime } from '../utils/date'; 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; const raw = metadata?.page_count;
if (typeof raw === 'number') { if (typeof raw === 'number') {
return Number.isFinite(raw) && raw >= 0 ? raw : null; return Number.isFinite(raw) && raw >= 0 ? raw : null;
} }
if (raw != null && raw !== '') { if (raw != null && raw !== '') {
const parsed = Number.parseInt(raw, 10); const parsed = Number.parseInt(String(raw), 10);
if (Number.isFinite(parsed) && parsed >= 0) { if (Number.isFinite(parsed) && parsed >= 0) {
return parsed; return parsed;
} }
@@ -15,21 +73,14 @@ const coercePageCount = (metadata) => {
return null; return null;
}; };
const sanitizeTags = (tags) => { const sanitizeArray = <T>(entries: (T | null | undefined)[] | null | undefined): T[] =>
if (!Array.isArray(tags)) { Array.isArray(entries) ? entries.filter(Boolean) as T[] : [];
return [];
}
return tags.filter(Boolean);
};
const sanitizeCorrespondents = (entries) => { export const describeDocumentSummary = (document?: SummaryDocument | null, options: DescribeSummaryOptions = {}): DocumentSummary => {
if (!Array.isArray(entries)) { const {
return []; formatDateTime = defaultFormatDateTime,
} } = options;
return entries.filter(Boolean);
};
export const describeDocumentSummary = (document, options = {}) => {
if (!document) { if (!document) {
return { return {
title: '', title: '',
@@ -50,10 +101,6 @@ export const describeDocumentSummary = (document, options = {}) => {
}; };
} }
const {
formatDateTime = defaultFormatDateTime,
} = options;
const originalName = document.original_name; const originalName = document.original_name;
const mimeTypeLabel = document.content_type || 'Unknown'; const mimeTypeLabel = document.content_type || 'Unknown';
@@ -70,18 +117,18 @@ export const describeDocumentSummary = (document, options = {}) => {
const folderLabel = document.folder_path; const folderLabel = document.folder_path;
const tags = sanitizeTags(document.tags); const tags = sanitizeArray<TagEntry>(document.tags);
const correspondents = sanitizeCorrespondents(document.correspondents); const correspondents = sanitizeArray<CorrespondentEntry>(document.correspondents);
const tagLabels = tags.map((tag) => tag.label).filter(Boolean); const tagLabels = tags.map((tag) => tag.label).filter(Boolean) as string[];
const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean); const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean) as string[];
const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—'; const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—';
const correspondentsSummary = correspondentLabels.length const correspondentsSummary = correspondentLabels.length
? correspondentLabels.join(', ') ? correspondentLabels.join(', ')
: '—'; : '—';
const summaryRows = [ const summaryRows: DocumentSummaryRow[] = [
{ key: 'created', label: 'Created', value: createdAtLabel }, { key: 'created', label: 'Created', value: createdAtLabel },
{ key: 'size', label: 'Size', value: sizeLabel }, { key: 'size', label: 'Size', value: sizeLabel },
{ key: 'type', label: 'Type', value: mimeTypeLabel }, { key: 'type', label: 'Type', value: mimeTypeLabel },
@@ -1,5 +1,36 @@
import { useCallback } from 'react'; 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 = ({ const useBulkDocumentActions = ({
api, api,
resolveTargetDocumentIds, resolveTargetDocumentIds,
@@ -13,9 +44,9 @@ const useBulkDocumentActions = ({
handleFolderDelete, handleFolderDelete,
clearDocumentSelection, clearDocumentSelection,
setLoading, setLoading,
}) => { }: UseBulkDocumentActionsArgs) => {
const handleBulkCorrespondentAdd = useCallback( const handleBulkCorrespondentAdd = useCallback(
async ({ name, input, documentIds }) => { async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
const trimmed = name?.trim?.() || ''; const trimmed = name?.trim?.() || '';
if (!trimmed) { if (!trimmed) {
setStatusMessage('Correspondent name is required.', 'error'); setStatusMessage('Correspondent name is required.', 'error');
@@ -41,7 +72,7 @@ const useBulkDocumentActions = ({
return; return;
} }
const response = await api.post('/documents/bulk/correspondents', { const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
document_ids: targets, document_ids: targets,
assignments: [ assignments: [
{ {
@@ -51,7 +82,7 @@ const useBulkDocumentActions = ({
action: 'add', action: 'add',
}); });
const { assigned = 0, removed = 0 } = response.data || {}; const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
await refreshCurrentFolder(); await refreshCurrentFolder();
const assignedSuffix = assigned === 1 ? '' : 's'; const assignedSuffix = assigned === 1 ? '' : 's';
@@ -83,7 +114,7 @@ const useBulkDocumentActions = ({
); );
const handleBulkCorrespondentRemove = useCallback( const handleBulkCorrespondentRemove = useCallback(
async ({ assignments = [], documentIds }) => { async ({ assignments = [], documentIds }: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => {
if (!assignments.length) { if (!assignments.length) {
setStatusMessage('Select a correspondent to remove.', 'error'); setStatusMessage('Select a correspondent to remove.', 'error');
return; return;
@@ -100,13 +131,13 @@ const useBulkDocumentActions = ({
correspondent_id: entry.correspondent_id, 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, document_ids: targets,
assignments: normalizedAssignments, assignments: normalizedAssignments,
action: 'remove', action: 'remove',
}); });
const { assigned = 0, removed = 0 } = response.data || {}; const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
await refreshCurrentFolder(); await refreshCurrentFolder();
if (removed > 0) { 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;
@@ -1,5 +1,41 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'; 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 = ({ const useDocumentsSelection = ({
showingSearchResults, showingSearchResults,
currentSubfolders, currentSubfolders,
@@ -18,9 +54,9 @@ const useDocumentsSelection = ({
setFocusedRowKey, setFocusedRowKey,
focusedRowKey, focusedRowKey,
isFolderRowKey, isFolderRowKey,
}) => { }: UseDocumentsSelectionOptions) => {
const navigableRows = useMemo(() => { const navigableRows = useMemo<NavigableRow[]>(() => {
const entries = []; const entries: NavigableRow[] = [];
if (!showingSearchResults) { if (!showingSearchResults) {
currentSubfolders.forEach((folder) => { currentSubfolders.forEach((folder) => {
const key = resolveFolderRowKey(folder.id); const key = resolveFolderRowKey(folder.id);
@@ -51,7 +87,7 @@ const useDocumentsSelection = ({
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]); }, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
const promoteSelectionOrder = useCallback( const promoteSelectionOrder = useCallback(
(docId) => { (docId: string | number | null) => {
if (!docId) return; if (!docId) return;
promoteSelectionOrderRaw(docId); promoteSelectionOrderRaw(docId);
const rowKey = resolveDocumentRowKey(docId); const rowKey = resolveDocumentRowKey(docId);
@@ -68,7 +104,7 @@ const useDocumentsSelection = ({
clearSelection(); clearSelection();
}, [clearSelection]); }, [clearSelection]);
const prevFocusedDocIdRef = useRef(focusedDocumentId); const prevFocusedDocIdRef = useRef<string | number | null>(focusedDocumentId);
useEffect(() => { useEffect(() => {
const previous = prevFocusedDocIdRef.current; const previous = prevFocusedDocIdRef.current;
if (previous === focusedDocumentId) { if (previous === focusedDocumentId) {
@@ -15,7 +15,11 @@ const EntryType = {
document: 'document', document: 'document',
}; };
const DocumentsPanel = ({ interface DocumentsPanelProps {
[key: string]: any;
}
const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
currentFolderName, currentFolderName,
breadcrumbs, breadcrumbs,
onRefresh, onRefresh,
@@ -1,4 +1,4 @@
import React from 'react'; import type { JSX } from 'react';
import { import {
ViewListIcon, ViewListIcon,
ViewGridIcon, ViewGridIcon,
@@ -12,18 +12,34 @@ import {
} from '../../ui/icons'; } from '../../ui/icons';
import SortFieldQuickMenu from './SortFieldQuickMenu'; 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 = ({ export const createDocumentsTableHeaderActions = ({
viewMode, viewMode = 'list',
onViewModeChange, onViewModeChange,
onRefresh, onRefresh,
sortField = 'title', sortField = 'title',
onSortFieldChange = null, onSortFieldChange,
sortDirection = 'asc', sortDirection = 'asc',
onSortDirectionToggle = null, onSortDirectionToggle,
isFilterActive = false, isFilterActive = false,
includeDescendants = true, includeDescendants = true,
onToggleIncludeDescendants = null, onToggleIncludeDescendants,
}) => { }: DocumentsTableHeaderActionOptions): JSX.Element => {
const isListView = viewMode === 'list'; const isListView = viewMode === 'list';
const isGridView = viewMode === 'grid'; const isGridView = viewMode === 'grid';
const isDeskView = viewMode === 'desk'; const isDeskView = viewMode === 'desk';
@@ -14,7 +14,12 @@ const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((acc, option) => {
return next; return next;
}, {}); }, {});
const SortFieldQuickMenu = ({ sortField, onChange }) => { interface SortFieldQuickMenuProps {
sortField: string;
onChange?: (value: string) => void;
}
const SortFieldQuickMenu: React.FC<SortFieldQuickMenuProps> = ({ sortField, onChange }) => {
const currentOption = useMemo( const currentOption = useMemo(
() => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0], () => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0],
[sortField], [sortField],
@@ -26,8 +31,8 @@ const SortFieldQuickMenu = ({ sortField, onChange }) => {
); );
const handleSelect = useCallback( const handleSelect = useCallback(
(value, option) => { (value: string, option?: { id?: string; original?: { id?: string } }) => {
if (typeof onChange !== 'function') { if (!onChange) {
return; return;
} }
const nextValue = option?.id || option?.original?.id || value; const nextValue = option?.id || option?.original?.id || value;
@@ -1,10 +1,26 @@
import React from 'react'; import React, { ReactNode } from 'react';
import DocumentViewerPanel from '../../preview/DocumentViewerPanel'; import DocumentViewerPanel from '../../preview/DocumentViewerPanel';
import SelectionFloatingActions from '../SelectionFloatingActions'; import SelectionFloatingActions from '../SelectionFloatingActions';
import createWorkspaceSurfaceConfig from '../workspaceHeader'; import createWorkspaceSurfaceConfig from '../workspaceHeader';
import DocumentsPanel from './DocumentsPanel'; import DocumentsPanel from './DocumentsPanel';
import { createDocumentsTableHeaderActions } from './DocumentsToolbar'; 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 = ({ const createDocumentsSurface = ({
tableProps, tableProps,
parentBreadcrumb, parentBreadcrumb,
@@ -12,7 +28,7 @@ const createDocumentsSurface = ({
renderSidebarToggle, renderSidebarToggle,
detailProps, detailProps,
detailOpen = false, detailOpen = false,
}) => { }: CreateDocumentsSurfaceArgs) => {
const { const {
currentFolderName, currentFolderName,
breadcrumbs, breadcrumbs,
@@ -1,7 +1,18 @@
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const TAG_TEXT_MIME_TYPE = 'text/plain'; 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 { try {
return JSON.stringify(payload); return JSON.stringify(payload);
} catch (error) { } catch (error) {
@@ -10,8 +21,8 @@ const serializePayload = (payload) => {
} }
}; };
export const createTagTransferPayload = (tag, sourceDocId = null) => { export const createTagTransferPayload = (tag: TagLike | null | undefined, sourceDocId: string | number | null = null): TagPayload | null => {
if (!tag || !tag.id) { if (!tag || tag.id == null) {
return 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) { if (!dataTransfer) {
return; return;
} }
@@ -38,8 +49,7 @@ export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => {
} }
try { try {
dataTransfer.setData(TAG_MIME_TYPES[0], serialized); TAG_MIME_TYPES.forEach((type) => dataTransfer.setData(type, serialized));
dataTransfer.setData(TAG_MIME_TYPES[1], serialized);
if (payload.label) { if (payload.label) {
dataTransfer.setData(TAG_TEXT_MIME_TYPE, 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) { if (!dataTransfer) {
return null; return null;
} }
@@ -67,15 +77,17 @@ export const readTagTransferData = (dataTransfer) => {
return null; 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 dataTransfer = input && 'dataTransfer' in input ? input.dataTransfer : input;
const raw = readTagTransferData(dataTransfer); const raw = readTagTransferData(dataTransfer || null);
if (!raw) { if (!raw) {
return null; return null;
} }
try { try {
return JSON.parse(raw); return JSON.parse(raw) as TagPayload;
} catch (error) { } catch (error) {
console.warn('[tagTransfer] Failed to parse drag payload', error); console.warn('[tagTransfer] Failed to parse drag payload', error);
} }
@@ -83,7 +95,7 @@ export const parseTagTransferPayload = (input) => {
return null; return null;
}; };
export const isTagTransferEvent = (event) => { export const isTagTransferEvent = (event?: DragEventLike | null): boolean => {
const types = event?.dataTransfer?.types; const types = event?.dataTransfer?.types;
if (!types) { if (!types) {
return false; return false;
-65
View File
@@ -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;
+79
View File
@@ -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;
-132
View File
@@ -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;
+166
View File
@@ -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;
-41
View File
@@ -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;
@@ -1,4 +1,52 @@
import { useCallback, useEffect, useRef } from 'react'; 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 = ({ const useAuthManager = ({
apiClient, apiClient,
@@ -8,16 +56,16 @@ const useAuthManager = ({
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
setLoading, setLoading,
}) => { }: UseAuthManagerArgs): UseAuthManagerResult => {
const tokenRef = useRef(token); const tokenRef = useRef<string | null | undefined>(token);
const refreshPromiseRef = useRef(null); const refreshPromiseRef = useRef<Promise<string> | null>(null);
const initialRefreshAttemptedRef = useRef(Boolean(token)); const initialRefreshAttemptedRef = useRef(Boolean(token));
const refreshAccessToken = useCallback(async () => { const refreshAccessToken = useCallback(async (): Promise<string> => {
console.log('[Auth] Attempting to refresh access token…'); console.log('[Auth] Attempting to refresh access token…');
appDispatch({ type: 'TOKEN_REFRESH_START' }); appDispatch({ type: 'TOKEN_REFRESH_START' });
try { try {
const { data } = await apiClient.post('/auth/refresh'); const { data } = await apiClient.post<{ access_token?: string; tenant?: unknown }>('/auth/refresh');
if (data?.access_token) { if (data?.access_token) {
appDispatch({ appDispatch({
type: 'TOKEN_REFRESH_SUCCESS', type: 'TOKEN_REFRESH_SUCCESS',
@@ -30,7 +78,7 @@ const useAuthManager = ({
throw new Error('Missing access token in refresh response'); throw new Error('Missing access token in refresh response');
} catch (error) { } catch (error) {
console.warn('[Auth] Failed to refresh access token', 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; throw error;
} }
}, [apiClient, appDispatch]); }, [apiClient, appDispatch]);
@@ -51,10 +99,11 @@ const useAuthManager = ({
const requestInterceptor = apiClient.interceptors.request.use((config) => { const requestInterceptor = apiClient.interceptors.request.use((config) => {
const currentToken = tokenRef.current; const currentToken = tokenRef.current;
if (currentToken) { if (currentToken) {
config.headers = config.headers || {}; const headers = ensureAxiosHeaders(config.headers);
if (!config.headers.Authorization) { if (!headers.has('Authorization')) {
config.headers.Authorization = `Bearer ${currentToken}`; headers.set('Authorization', `Bearer ${currentToken}`);
} }
config.headers = headers;
} }
return config; return config;
}); });
@@ -62,7 +111,8 @@ const useAuthManager = ({
const responseInterceptor = apiClient.interceptors.response.use( const responseInterceptor = apiClient.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
const { response, config } = error; const axiosError = error as AxiosError & { config?: RetryableAxiosRequestConfig };
const { response, config } = axiosError;
if (!response || !config) { if (!response || !config) {
return Promise.reject(error); return Promise.reject(error);
} }
@@ -90,13 +140,12 @@ const useAuthManager = ({
throw new Error('No token returned from refresh'); throw new Error('No token returned from refresh');
} }
config._retry = true; config._retry = true;
config.headers = config.headers || {}; setHeaderAuthorization(config, newToken);
config.headers.Authorization = `Bearer ${newToken}`;
console.log('[Auth] Retrying original request', url); console.log('[Auth] Retrying original request', url);
try { try {
return await apiClient(config); return await apiClient(config);
} catch (retryError) { } catch (retryError) {
if (retryError?.response?.status === 401) { if ((retryError as AxiosError)?.response?.status === 401) {
notifyApiError(retryError, 'Session expired. Please log in again.'); notifyApiError(retryError, 'Session expired. Please log in again.');
} }
throw retryError; throw retryError;
@@ -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 = ({ const useCorrespondents = ({
apiClient, apiClient,
@@ -6,8 +27,8 @@ const useCorrespondents = ({
setStatusMessage, setStatusMessage,
tenantIdRef, tenantIdRef,
mapDocumentCaches, mapDocumentCaches,
}) => { }: UseCorrespondentsOptions) => {
const [correspondents, setCorrespondents] = useState([]); const [correspondents, setCorrespondents] = useState<CorrespondentEntry[]>([]);
const refreshCorrespondents = useCallback(async () => { const refreshCorrespondents = useCallback(async () => {
const requestTenantId = tenantIdRef.current; const requestTenantId = tenantIdRef.current;
@@ -26,12 +47,12 @@ const useCorrespondents = ({
}, [apiClient, notifyApiError, tenantIdRef]); }, [apiClient, notifyApiError, tenantIdRef]);
const handleCorrespondentUpdate = useCallback( const handleCorrespondentUpdate = useCallback(
async (correspondentId, changes) => { async (correspondentId: string | number | null | undefined, changes: { name?: string }) => {
if (!correspondentId) { if (!correspondentId) {
throw new Error('Missing correspondent identifier.'); throw new Error('Missing correspondent identifier.');
} }
const payload = {}; const payload: Record<string, unknown> = {};
if (typeof changes?.name?.trim === 'function') { if (typeof changes?.name?.trim === 'function') {
const trimmed = changes.name.trim(); const trimmed = changes.name.trim();
if (!trimmed) { if (!trimmed) {
@@ -59,7 +80,7 @@ const useCorrespondents = ({
); );
const handleCorrespondentCreate = useCallback( const handleCorrespondentCreate = useCallback(
async ({ name }) => { async ({ name }: { name?: string }) => {
const trimmed = name?.trim?.() || ''; const trimmed = name?.trim?.() || '';
if (!trimmed) { if (!trimmed) {
throw new Error('Correspondent name is required.'); throw new Error('Correspondent name is required.');
@@ -79,12 +100,12 @@ const useCorrespondents = ({
); );
const handleCorrespondentDelete = useCallback( const handleCorrespondentDelete = useCallback(
async (correspondentId) => { async (correspondentId: string | number | null | undefined) => {
if (!correspondentId) { if (!correspondentId) {
throw new Error('Missing correspondent identifier.'); throw new Error('Missing correspondent identifier.');
} }
const stripFromDoc = (doc) => { const stripFromDoc = (doc: any) => {
if (!doc || !Array.isArray(doc.correspondents)) { if (!doc || !Array.isArray(doc.correspondents)) {
return doc; return doc;
} }
@@ -1,5 +1,25 @@
import { useCallback, useMemo } from 'react'; 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 = ({ const useDocumentCorrespondentActions = ({
apiClient, apiClient,
correspondents, correspondents,
@@ -7,9 +27,9 @@ const useDocumentCorrespondentActions = ({
refreshCurrentFolder, refreshCurrentFolder,
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
}) => { }: UseDocumentCorrespondentActionsArgs) => {
const correspondentLookupByName = useMemo(() => { const correspondentLookupByName = useMemo(() => {
const map = new Map(); const map = new Map<string, CorrespondentOption>();
correspondents.forEach((correspondent) => { correspondents.forEach((correspondent) => {
if (correspondent?.name) { if (correspondent?.name) {
map.set(correspondent.name.toLowerCase(), correspondent); map.set(correspondent.name.toLowerCase(), correspondent);
@@ -19,7 +39,10 @@ const useDocumentCorrespondentActions = ({
}, [correspondents]); }, [correspondents]);
const handleDocumentCorrespondentAttach = useCallback( 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) { if (!documentId || !correspondentId) {
throw new Error('Missing document or correspondent.'); throw new Error('Missing document or correspondent.');
} }
@@ -45,7 +68,10 @@ const useDocumentCorrespondentActions = ({
); );
const handleCorrespondentRemove = useCallback( 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) { if (!documentId || !correspondentId) {
throw new Error('Missing document or correspondent.'); throw new Error('Missing document or correspondent.');
} }
@@ -68,7 +94,7 @@ const useDocumentCorrespondentActions = ({
); );
const handleCorrespondentAdd = useCallback( 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) { if (!document?.id) {
throw new Error('Missing document for correspondent assignment.'); throw new Error('Missing document for correspondent assignment.');
} }
@@ -1,4 +1,38 @@
import { useCallback, useEffect, useRef } from 'react'; 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 = ({ const useDocumentDragHandlers = ({
selectedEntries, selectedEntries,
@@ -12,8 +46,8 @@ const useDocumentDragHandlers = ({
resolveDocumentRowKey, resolveDocumentRowKey,
resolveFolderRowKey, resolveFolderRowKey,
documentsViewMode, documentsViewMode,
}) => { }: UseDocumentDragHandlersOptions) => {
const dragPreviewRef = useRef(null); const dragPreviewRef = useRef<HTMLDivElement | null>(null);
const destroyDragPreview = useCallback(() => { const destroyDragPreview = useCallback(() => {
const node = dragPreviewRef.current; const node = dragPreviewRef.current;
@@ -26,7 +60,7 @@ const useDocumentDragHandlers = ({
useEffect(() => destroyDragPreview, [destroyDragPreview]); useEffect(() => destroyDragPreview, [destroyDragPreview]);
const createDragPreview = useCallback( const createDragPreview = useCallback(
({ documents = [], folders = [] } = {}) => { ({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: Array<FolderIdentifier | Identifier> } = {}) => {
destroyDragPreview(); destroyDragPreview();
const docEntries = (documents || []).filter(Boolean); const docEntries = (documents || []).filter(Boolean);
@@ -155,8 +189,11 @@ const useDocumentDragHandlers = ({
); );
const handleDocumentDragStart = useCallback( const handleDocumentDragStart = useCallback(
(event, documentOrId) => { (event: DragEvent<HTMLElement>, documentOrId: DocumentLike | Identifier | null | undefined) => {
const documentId = documentOrId?.id ?? (typeof documentOrId?.trim === 'function' ? documentOrId : null); const documentId =
(documentOrId as DocumentLike)?.id ?? (typeof documentOrId === 'string' || typeof documentOrId === 'number'
? documentOrId
: null);
if (!documentId) { if (!documentId) {
return; return;
} }
@@ -168,12 +205,12 @@ const useDocumentDragHandlers = ({
const isGridView = documentsViewMode === 'grid'; const isGridView = documentsViewMode === 'grid';
const isAlreadySelected = selectedDocumentIds.includes(documentId); const isAlreadySelected = selectedDocumentIds.includes(documentId);
const selection = isAlreadySelected const selection: Identifier[] = isAlreadySelected
? [...selectedDocumentIds] ? [...selectedDocumentIds]
: isGridView : isGridView
? [...selectedDocumentIds, documentId] ? [...selectedDocumentIds, documentId]
: [documentId]; : [documentId];
const folderSelection = []; const folderSelection: FolderIdentifier[] = [];
if (!isAlreadySelected && !isGridView) { if (!isAlreadySelected && !isGridView) {
applySelection([documentKey], { 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({ const previewNode = createDragPreview({
documents: previewDocs, documents: previewDocs,
folders: folderSelection, folders: folderSelection,
@@ -230,7 +269,7 @@ const useDocumentDragHandlers = ({
); );
const handleDocumentDragEnd = useCallback( const handleDocumentDragEnd = useCallback(
(event) => { (event: DragEvent<HTMLElement>) => {
setDraggedDocumentIds([]); setDraggedDocumentIds([]);
event.currentTarget.classList.remove('dragging'); event.currentTarget.classList.remove('dragging');
destroyDragPreview(); destroyDragPreview();
@@ -240,7 +279,7 @@ const useDocumentDragHandlers = ({
); );
const handleFolderDragStart = useCallback( const handleFolderDragStart = useCallback(
(event, folderId) => { (event: DragEvent<HTMLElement>, folderId: FolderIdentifier) => {
if (folderId === 'root') { if (folderId === 'root') {
return; return;
} }
@@ -248,8 +287,8 @@ const useDocumentDragHandlers = ({
const folderKey = resolveFolderRowKey(folderId); const folderKey = resolveFolderRowKey(folderId);
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false; const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
let effectiveFolderSelection = selectedFolderIds; let effectiveFolderSelection: FolderIdentifier[] = selectedFolderIds;
let effectiveDocumentSelection = selectedDocumentIds; let effectiveDocumentSelection: Identifier[] = selectedDocumentIds;
if (!isAlreadySelected && folderKey) { if (!isAlreadySelected && folderKey) {
effectiveFolderSelection = [folderId]; effectiveFolderSelection = [folderId];
@@ -287,7 +326,7 @@ const useDocumentDragHandlers = ({
const previewNode = createDragPreview({ const previewNode = createDragPreview({
documents: effectiveDocumentSelection documents: effectiveDocumentSelection
.map((id) => documentLookup.get(id) || null) .map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
.filter(Boolean), .filter(Boolean),
folders: uniqueFolders, folders: uniqueFolders,
}); });
@@ -313,7 +352,7 @@ const useDocumentDragHandlers = ({
); );
const handleFolderDragEnd = useCallback( const handleFolderDragEnd = useCallback(
(event) => { (event?: DragEvent<HTMLElement>) => {
if (event?.currentTarget) { if (event?.currentTarget) {
event.currentTarget.classList.remove('dragging'); event.currentTarget.classList.remove('dragging');
} }
@@ -1,12 +1,182 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils'; 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 (!value) return null;
if (typeof value === 'object' && value.id) { if (typeof value === 'object' && value !== null && 'id' in value && value.id != null) {
return value.id; return value.id as DocumentId;
} }
return value; return value as DocumentId;
}; };
const useDocumentMutations = ({ const useDocumentMutations = ({
@@ -46,36 +216,34 @@ const useDocumentMutations = ({
refreshTags, refreshTags,
tagManager, tagManager,
extractDocumentFromResponse, extractDocumentFromResponse,
}) => { }: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
const moveDocumentsToFolder = useCallback( const moveDocumentsToFolder = useCallback(
async (documentIds, targetFolderId) => { async (documentIds: Array<DocumentId | DocumentLike>, targetFolderId?: NullableFolderId) => {
const uniqueIds = Array.from( 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; if (!uniqueIds.length) return;
const uniqueIdSet = new Set(uniqueIds); const uniqueIdSet = new Set(uniqueIds);
const target = targetFolderId === 'root' ? null : targetFolderId; const target = targetFolderId === 'root' ? null : targetFolderId ?? null;
const targetLabel = const targetLabel =
target === null target === null ? DEFAULT_FOLDER_NAME : folderLabelMap.get(targetFolderId as FolderId) || 'target folder';
? DEFAULT_FOLDER_NAME
: folderLabelMap.get(targetFolderId) || 'target folder';
const movedDocs = uniqueIds const movedDocs = uniqueIds
.map((id) => { .map((id) => {
const doc = documentLookup.get(id); const doc = documentLookup.get(id) || null;
if (!doc) { if (!doc) {
return null; return null;
} }
return { return {
id, id,
sourceFolderId: doc.folder_id ?? null, sourceFolderId: (doc.folder_id ?? null) as NullableFolderId,
document: doc, 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 = () => { const resolveTargetName = () => {
if (!targetLabel) { if (!targetLabel) {
return null; return null;
@@ -89,7 +257,7 @@ const useDocumentMutations = ({
if (!document) { if (!document) {
return; return;
} }
const updated = { const updated: DocumentLike = {
...document, ...document,
folder_id: target, folder_id: target,
}; };
@@ -105,13 +273,13 @@ const useDocumentMutations = ({
updatedDocsMap.set(id, updated); updatedDocsMap.set(id, updated);
}); });
const pruneRow = (collection) => const pruneRow = (collection: string[]): string[] =>
collection.filter((key) => { collection.filter((key) => {
if (!isDocumentRowKey(key)) { if (!isDocumentRowKey(key)) {
return true; return true;
} }
const id = getRowId(key); const id = getRowId(key);
return id ? !uniqueIdSet.has(id) : true; return id ? !uniqueIdSet.has(id as DocumentId) : true;
}); });
setLoading(true); setLoading(true);
@@ -131,10 +299,10 @@ const useDocumentMutations = ({
if (updatedDocsMap.size) { if (updatedDocsMap.size) {
mapDocumentCaches((doc) => { mapDocumentCaches((doc) => {
if (!doc || !uniqueIdSet.has(doc.id)) { if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
return doc; return doc;
} }
const updated = updatedDocsMap.get(doc.id); const updated = updatedDocsMap.get(doc.id as DocumentId);
if (updated) { if (updated) {
return updated; return updated;
} }
@@ -142,7 +310,7 @@ const useDocumentMutations = ({
}); });
} else { } else {
mapDocumentCaches((doc) => { mapDocumentCaches((doc) => {
if (!doc || !uniqueIdSet.has(doc.id)) { if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
return doc; return doc;
} }
return { ...doc, folder_id: target }; return { ...doc, folder_id: target };
@@ -154,10 +322,10 @@ const useDocumentMutations = ({
if (!Array.isArray(prev) || !prev.length) { if (!Array.isArray(prev) || !prev.length) {
return prev; 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; 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) => { setFolderContents((prev) => {
if (!prev.size) { if (!prev.size) {
return prev; return prev;
@@ -165,7 +333,7 @@ const useDocumentMutations = ({
let changed = false; let changed = false;
const next = new Map(prev); const next = new Map(prev);
movedDocs.forEach(({ id, sourceFolderId }) => { movedDocs.forEach(({ id, sourceFolderId }) => {
const sourceKey = sourceFolderId || 'root'; const sourceKey = (sourceFolderId || 'root') as FolderId;
const entry = next.get(sourceKey); const entry = next.get(sourceKey);
if (!entry?.documents?.length) { if (!entry?.documents?.length) {
return; return;
@@ -179,13 +347,14 @@ const useDocumentMutations = ({
return changed ? next : prev; return changed ? next : prev;
}); });
setSelectedEntries((prev) => pruneRow(prev, uniqueIdSet)); setSelectedEntries((prev) => pruneRow(prev));
setSelectionOrder((prev) => pruneRow(prev, uniqueIdSet)); setSelectionOrder((prev) => pruneRow(prev));
selectionOrderRef.current = pruneRow(selectionOrderRef.current || [], uniqueIdSet); const nextSelectionOrder = pruneRow(selectionOrderRef.current || []);
selectionOrderRef.current = nextSelectionOrder;
if ( if (
selectionAnchorRef.current && selectionAnchorRef.current &&
isDocumentRowKey(selectionAnchorRef.current) && isDocumentRowKey(selectionAnchorRef.current) &&
uniqueIdSet.has(getRowId(selectionAnchorRef.current)) uniqueIdSet.has(getRowId(selectionAnchorRef.current) as DocumentId)
) { ) {
selectionAnchorRef.current = null; selectionAnchorRef.current = null;
} }
@@ -195,17 +364,17 @@ const useDocumentMutations = ({
if ( if (
focusedRowKey && focusedRowKey &&
isDocumentRowKey(focusedRowKey) && isDocumentRowKey(focusedRowKey) &&
uniqueIdSet.has(getRowId(focusedRowKey)) uniqueIdSet.has(getRowId(focusedRowKey) as DocumentId)
) { ) {
setFocusedRowKey(null); setFocusedRowKey(null);
} }
} }
if (targetFolderId && targetFolderId !== selectedFolder) { if (targetFolderId && targetFolderId !== selectedFolder) {
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); await ensureFolderData(targetFolderId as FolderId, { force: true, prefetchDepth: 1 });
} }
} catch (error) { } 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); notifyApiError(error, message);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -236,7 +405,7 @@ const useDocumentMutations = ({
); );
const handleThumbnailRegeneration = useCallback( const handleThumbnailRegeneration = useCallback(
async (documentId) => { async (documentId: DocumentId) => {
if (!token) { if (!token) {
setStatusMessage('Log in to manage assets.', 'error'); setStatusMessage('Log in to manage assets.', 'error');
return; return;
@@ -249,7 +418,7 @@ const useDocumentMutations = ({
setStatusMessage('Document re-analysis queued.', 'info'); setStatusMessage('Document re-analysis queued.', 'info');
await refreshCurrentFolder(); await refreshCurrentFolder();
} catch (error) { } 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); notifyApiError(error, message);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -259,7 +428,7 @@ const useDocumentMutations = ({
); );
const handleDocumentsDelete = useCallback( const handleDocumentsDelete = useCallback(
async (documentIds, { showMessage = true, manageLoading = true } = {}) => { async (documentIds: DocumentId[], { showMessage = true, manageLoading = true }: DeleteOptions = {}) => {
if (!documentIds || documentIds.length === 0) { if (!documentIds || documentIds.length === 0) {
return false; return false;
} }
@@ -274,13 +443,11 @@ const useDocumentMutations = ({
} }
try { try {
await Promise.all( await Promise.all(documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)));
documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)),
);
removeDocumentsFromCaches(documentIds); removeDocumentsFromCaches(documentIds);
if (documentIds.includes(previewDocumentId)) { if (previewDocumentId && documentIds.includes(previewDocumentId)) {
closeDocumentPreview(); closeDocumentPreview();
} }
@@ -290,7 +457,7 @@ const useDocumentMutations = ({
} }
return true; return true;
} catch (error) { } 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); notifyApiError(error, message);
return false; return false;
} finally { } finally {
@@ -312,7 +479,7 @@ const useDocumentMutations = ({
); );
const handleDocumentTitleUpdate = useCallback( const handleDocumentTitleUpdate = useCallback(
async (documentId, nextTitle) => { async (documentId: DocumentId, nextTitle: string) => {
const trimmed = nextTitle?.trim?.() || ''; const trimmed = nextTitle?.trim?.() || '';
if (!trimmed) { if (!trimmed) {
setStatusMessage('Document title cannot be empty.', 'error'); setStatusMessage('Document title cannot be empty.', 'error');
@@ -334,7 +501,7 @@ const useDocumentMutations = ({
setStatusMessage('Document title updated.', 'success'); setStatusMessage('Document title updated.', 'success');
return true; return true;
} catch (error) { } 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); notifyApiError(error, message);
return false; return false;
} finally { } finally {
@@ -345,7 +512,7 @@ const useDocumentMutations = ({
); );
const handleDocumentIssuedUpdate = useCallback( const handleDocumentIssuedUpdate = useCallback(
async (documentId, nextIssuedDate) => { async (documentId: DocumentId, nextIssuedDate: number | null) => {
setLoading(true); setLoading(true);
const payload = { issued_at: nextIssuedDate || null }; const payload = { issued_at: nextIssuedDate || null };
try { try {
@@ -363,7 +530,7 @@ const useDocumentMutations = ({
setStatusMessage(message, 'success'); setStatusMessage(message, 'success');
return true; return true;
} catch (error) { } 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); notifyApiError(error, message);
return false; return false;
} finally { } finally {
@@ -374,26 +541,23 @@ const useDocumentMutations = ({
); );
const handleDocumentTagAdd = useCallback( const handleDocumentTagAdd = useCallback(
async (document, label, extras = null) => { async (document: DocumentLike, label: string, extras: DocumentTagExtras | null = null) => {
const normalizedLabel = tagManager.normalizeLabel(label); const normalizedLabel = tagManager.normalizeLabel(label);
const optionCandidate = const optionCandidate = extras?.option ?? null;
extras && typeof extras === 'object' && 'option' in extras ? extras.option : null; const input = extras?.input ?? null;
const input =
extras && typeof extras === 'object' && 'input' in extras ? extras.input : null;
let tag = null; let tag: Tag | null = null;
if (optionCandidate && optionCandidate.id) { if (optionCandidate && optionCandidate.id) {
tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate; tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate;
} }
if (!tag) { if (!tag) {
tag = tag = tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
} }
try { try {
if (!tag) { if (!tag) {
const payload = tagManager.buildPayload({ label: normalizedLabel }); const payload = tagManager.buildPayload({ label: normalizedLabel });
const { data } = await api.post('/tags', payload); const { data } = await api.post('/tags', payload);
tag = data; tag = data as Tag;
await refreshTags(); await refreshTags();
} }
await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] }); await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
@@ -410,12 +574,12 @@ const useDocumentMutations = ({
); );
const handleDocumentTagAttach = useCallback( const handleDocumentTagAttach = useCallback(
async ({ documentId, tagId, tag: tagData = null }) => { async ({ documentId, tagId, tag: tagData = null }: TagAttachArgs) => {
if (!documentId || !tagId) { if (!documentId || !tagId) {
return false; return false;
} }
const resolveTagForCache = () => { const resolveTagForCache = (): Tag | null => {
const lookupTag = tagLookupById.get(tagId); const lookupTag = tagLookupById.get(tagId);
const source = lookupTag ?? tagData; const source = lookupTag ?? tagData;
if (!source || source.id == null || typeof source.label?.trim !== 'function') { if (!source || source.id == null || typeof source.label?.trim !== 'function') {
@@ -424,7 +588,7 @@ const useDocumentMutations = ({
return { return {
id: source.id, id: source.id,
label: source.label, 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; return true;
} catch (error) { } 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); notifyApiError(error, message);
return false; return false;
} }
@@ -467,16 +631,16 @@ const useDocumentMutations = ({
); );
const applyTagRemovalToCaches = useCallback( const applyTagRemovalToCaches = useCallback(
(documentId, tagId) => { (documentId?: DocumentId, tagId?: DocumentId) => {
if (!documentId || !tagId) { if (!documentId || !tagId) {
return; return;
} }
updateDocumentCaches(documentId, (doc) => { updateDocumentCaches(documentId, (doc) => {
if (!Array.isArray(doc.tags)) { if (!doc || !Array.isArray(doc.tags)) {
return doc; 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) { if (nextTags.length === doc.tags.length) {
return doc; return doc;
} }
@@ -487,7 +651,11 @@ const useDocumentMutations = ({
); );
const handleTagRemove = useCallback( const handleTagRemove = useCallback(
async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => { async (
documentId?: DocumentId,
tagId?: DocumentId,
{ refreshTagList = true, showMessage = true }: TagRemoveOptions = {},
) => {
if (!documentId || !tagId) { if (!documentId || !tagId) {
return false; return false;
} }
@@ -503,7 +671,7 @@ const useDocumentMutations = ({
} }
return true; return true;
} catch (error) { } 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); notifyApiError(error, message);
return false; return false;
} }
@@ -512,7 +680,7 @@ const useDocumentMutations = ({
); );
const handleFolderDelete = useCallback( const handleFolderDelete = useCallback(
async (folderId, { showMessage = true, manageLoading = true } = {}) => { async (folderId?: FolderId, { showMessage = true, manageLoading = true }: FolderDeleteOptions = {}) => {
if (!token) { if (!token) {
if (showMessage) { if (showMessage) {
setStatusMessage('Log in to manage folders.', 'error'); setStatusMessage('Log in to manage folders.', 'error');
@@ -589,7 +757,7 @@ const useDocumentMutations = ({
} }
return true; return true;
} catch (error) { } 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); notifyApiError(error, message);
if (showMessage) { if (showMessage) {
setStatusMessage(message, 'error'); setStatusMessage(message, 'error');
@@ -1,5 +1,47 @@
import { useCallback } from 'react'; 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 = ({ const useDocumentTagging = ({
apiClient, apiClient,
tags, tags,
@@ -10,9 +52,9 @@ const useDocumentTagging = ({
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
setLoading, setLoading,
}) => { }: UseDocumentTaggingArgs) => {
const bulkTagOperation = useCallback( 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); const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0);
if (!normalized.length) { if (!normalized.length) {
return { ok: false, reason: 'no-labels' }; return { ok: false, reason: 'no-labels' };
@@ -22,7 +64,7 @@ const useDocumentTagging = ({
return { ok: false, reason: 'no-selection' }; return { ok: false, reason: 'no-selection' };
} }
let tagIds = []; let tagIds: Identifier[] = [];
if (action === 'remove') { if (action === 'remove') {
const missing = normalized.find( const missing = normalized.find(
@@ -41,13 +83,13 @@ const useDocumentTagging = ({
setLoading(true); setLoading(true);
try { try {
if (action === 'add') { if (action === 'add') {
const createdIds = []; const createdIds: Identifier[] = [];
for (const label of normalized) { for (const label of normalized) {
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null; let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
if (!tag) { if (!tag) {
const payload = tagManager.buildPayload({ label }); const payload = tagManager.buildPayload({ label });
const { data } = await apiClient.post('/tags', payload); const response = await apiClient.post<{ id?: Identifier; label: string }>('/tags', payload);
tag = data; tag = 'data' in response ? response.data : response;
await refreshTags(); await refreshTags();
} }
createdIds.push(tag.id); createdIds.push(tag.id);
@@ -97,7 +139,7 @@ const useDocumentTagging = ({
); );
const handleBulkTagAddFromDetail = useCallback( const handleBulkTagAddFromDetail = useCallback(
async ({ label, input, documentIds }) => { async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
const trimmed = label?.trim?.() || ''; const trimmed = label?.trim?.() || '';
if (!trimmed) { if (!trimmed) {
setStatusMessage('Enter a tag label.', 'error'); setStatusMessage('Enter a tag label.', 'error');
@@ -130,7 +172,7 @@ const useDocumentTagging = ({
); );
const handleBulkTagRemoveFromDetail = useCallback( const handleBulkTagRemoveFromDetail = useCallback(
async ({ label, input, documentIds }) => { async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
const trimmed = label?.trim?.() || ''; const trimmed = label?.trim?.() || '';
if (!trimmed) { if (!trimmed) {
setStatusMessage('Enter a tag label to remove.', 'error'); setStatusMessage('Enter a tag label to remove.', 'error');
@@ -163,7 +205,7 @@ const useDocumentTagging = ({
); );
const handleBulkSelectionReanalyze = useCallback( const handleBulkSelectionReanalyze = useCallback(
async (documentIdsOverride = null) => { async (documentIdsOverride: Identifier[] | null = null) => {
const targetIds = resolveTargetDocumentIds(documentIdsOverride); const targetIds = resolveTargetDocumentIds(documentIdsOverride);
if (!targetIds.length) { if (!targetIds.length) {
setStatusMessage('Select documents before requesting re-analysis.', 'error'); setStatusMessage('Select documents before requesting re-analysis.', 'error');
@@ -1,8 +1,76 @@
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import useFileDrop from './useFileDrop'; import useFileDrop from './useFileDrop';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils'; 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) { if (!filesInput) {
return []; return [];
} }
@@ -10,7 +78,7 @@ const mapFilesToEntries = (filesInput) => {
return files return files
.filter(Boolean) .filter(Boolean)
.map((file) => { .map((file) => {
const relativePath = file?.webkitRelativePath ?? ''; const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
const segments = relativePath const segments = relativePath
? relativePath ? relativePath
.split('/') .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 = ({ const useDocumentUploads = ({
apiClient, apiClient,
token, token,
@@ -30,18 +127,18 @@ const useDocumentUploads = ({
refreshCurrentFolder, refreshCurrentFolder,
setLoading, setLoading,
shellRef, shellRef,
}) => { }: UseDocumentUploadsArgs): UseDocumentUploadsResult => {
const [dropOverlayState, setDropOverlayState] = useState({ const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({
active: false, active: false,
folderName: DEFAULT_FOLDER_NAME, folderName: currentFolderName || DEFAULT_FOLDER_NAME,
}); });
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const folderPathCacheRef = useRef(new Map()); const folderPathCacheRef = useRef<Map<string, FolderId>>(new Map());
const queueIdRef = useRef(0); const queueIdRef = useRef(0);
const [uploadQueue, setUploadQueue] = useState([]); const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
const uploadFile = useCallback( const uploadFile = useCallback(
async (file, targetFolderId) => { async (file: File, targetFolderId: FolderId) => {
if (!file || file.size === 0) { if (!file || file.size === 0) {
return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null }; return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null };
} }
@@ -62,14 +159,14 @@ const useDocumentUploads = ({
statusCode: status ?? (duplicate ? 200 : 201), statusCode: status ?? (duplicate ? 200 : 201),
conflictDocumentId: null, conflictDocumentId: null,
}; };
} catch (error) { } catch (error: any) {
if (error.response?.status === 409) { if (error.response?.status === 409) {
const conflictId = error.response?.data?.details?.conflict_document_id ?? null; const conflictId = error.response?.data?.details?.conflict_document_id ?? null;
let conflictDocument = null; let conflictDocument = null;
if (conflictId) { if (conflictId) {
try { try {
const { data } = await apiClient.get(`/documents/${conflictId}`); const { data } = await apiClient.get(`/documents/${conflictId}`);
conflictDocument = data?.document ?? data ?? null; conflictDocument = (data as any)?.document ?? data ?? null;
} catch (fetchError) { } catch (fetchError) {
console.warn('[Uploads] failed to fetch conflict document', fetchError); console.warn('[Uploads] failed to fetch conflict document', fetchError);
} }
@@ -89,7 +186,7 @@ const useDocumentUploads = ({
[apiClient], [apiClient],
); );
const appendQueueItems = useCallback((entries, targetFolderId) => { const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => {
const baseId = Date.now(); const baseId = Date.now();
const items = entries.map(({ file }) => { const items = entries.map(({ file }) => {
queueIdRef.current += 1; queueIdRef.current += 1;
@@ -98,12 +195,12 @@ const useDocumentUploads = ({
name: file?.name || 'Unnamed file', name: file?.name || 'Unnamed file',
size: file?.size ?? null, size: file?.size ?? null,
folderId: targetFolderId ?? selectedFolder ?? 'root', folderId: targetFolderId ?? selectedFolder ?? 'root',
status: 'pending', status: 'pending' as UploadStatus,
error: null, error: null,
code: null, code: null,
document: null, document: null,
conflictDocumentId: null, conflictDocumentId: null,
}; } satisfies UploadQueueItem;
}); });
if (items.length) { if (items.length) {
setUploadQueue((current) => [...current, ...items]); setUploadQueue((current) => [...current, ...items]);
@@ -111,7 +208,7 @@ const useDocumentUploads = ({
return items; return items;
}, [selectedFolder]); }, [selectedFolder]);
const updateQueueItem = useCallback((id, patch) => { const updateQueueItem = useCallback((id: string, patch: Partial<UploadQueueItem>) => {
if (!id) { if (!id) {
return; return;
} }
@@ -121,7 +218,7 @@ const useDocumentUploads = ({
}, []); }, []);
const ensureFolderPathOnServer = useCallback( const ensureFolderPathOnServer = useCallback(
async (baseFolderId, segments) => { async (baseFolderId: FolderId, segments: string[]): Promise<FolderId> => {
const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean); const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
if (trimmedSegments.length === 0) { if (trimmedSegments.length === 0) {
return baseFolderId ?? null; return baseFolderId ?? null;
@@ -130,7 +227,7 @@ const useDocumentUploads = ({
const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`; const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`;
const cache = folderPathCacheRef.current; const cache = folderPathCacheRef.current;
if (cache.has(cacheKey)) { if (cache.has(cacheKey)) {
return cache.get(cacheKey); return cache.get(cacheKey) ?? null;
} }
const payload = { const payload = {
@@ -145,21 +242,21 @@ const useDocumentUploads = ({
[apiClient], [apiClient],
); );
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => { const extractFilesFromDataTransfer = useCallback(async (dataTransfer: DataTransfer) => {
if (!dataTransfer) { if (!dataTransfer) {
throw new Error('No drop payload found.'); 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', { console.info('[Uploads] drop start', {
items: items.length, items: items.length,
files: (dataTransfer.files || []).length, files: (dataTransfer.files || []).length,
}); });
const results = []; const results: FileEntry[] = [];
const seenKeys = new Set(); const seenKeys = new Set();
const pushFile = (file, ancestors = []) => { const pushFile = (file?: File | null, ancestors: string[] = []) => {
if (!file) return; if (!file) return;
const segments = (ancestors || []).filter(Boolean); const segments = (ancestors || []).filter(Boolean);
const key = `${segments.join('/')}/${file.name}:${file.size}`; const key = `${segments.join('/')}/${file.name}:${file.size}`;
@@ -170,11 +267,11 @@ const useDocumentUploads = ({
results.push({ file, segments }); results.push({ file, segments });
}; };
const readAllEntries = async (reader) => { const readAllEntries = async (reader: FileSystemDirectoryReaderLike) => {
const entries = []; const entries: FileSystemEntryLike[] = [];
let batch = []; let batch: FileSystemEntryLike[] = [];
do { 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) { if (batch.length) {
entries.push(...batch); entries.push(...batch);
} }
@@ -182,15 +279,15 @@ const useDocumentUploads = ({
return entries; return entries;
}; };
const walkEntry = async (entry, ancestors = []) => { const walkEntry = async (entry: FileSystemEntryLike | null, ancestors: string[] = []) => {
if (!entry) return; if (!entry) return;
if (entry.isFile) { if (entry.isFile) {
const file = await new Promise((resolve, reject) => { const file = await new Promise<File>((resolve, reject) => {
try { try {
entry.file(resolve, reject); (entry as FileSystemFileEntryLike).file(resolve, reject);
} catch (error) { } catch (error) {
console.warn('[Uploads] entry.file failed', error); console.warn('[Uploads] entry.file failed', error);
reject(error); reject(error as Error);
} }
}); });
pushFile(file, ancestors); pushFile(file, ancestors);
@@ -198,7 +295,7 @@ const useDocumentUploads = ({
} }
if (entry.isDirectory) { if (entry.isDirectory) {
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors]; const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
const reader = entry.createReader(); const reader = (entry as FileSystemDirectoryEntryLike).createReader();
const entries = await readAllEntries(reader); const entries = await readAllEntries(reader);
for (const child of entries) { for (const child of entries) {
await walkEntry(child, nextAncestors); await walkEntry(child, nextAncestors);
@@ -212,7 +309,7 @@ const useDocumentUploads = ({
const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null; const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
if (fileFromItem) { if (fileFromItem) {
const relativePath = fileFromItem?.webkitRelativePath ?? ''; const relativePath = (fileFromItem as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
const segments = relativePath const segments = relativePath
? relativePath ? relativePath
.split('/') .split('/')
@@ -242,7 +339,7 @@ const useDocumentUploads = ({
Array.from(dataTransfer.files || []).forEach((file) => { Array.from(dataTransfer.files || []).forEach((file) => {
if (!file) return; if (!file) return;
const relativePath = file?.webkitRelativePath ?? ''; const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
const segments = relativePath const segments = relativePath
? relativePath ? relativePath
.split('/') .split('/')
@@ -322,7 +419,7 @@ const useDocumentUploads = ({
updateQueueItem(queueItem.id, patch); updateQueueItem(queueItem.id, patch);
Object.assign(queueItem, patch); Object.assign(queueItem, patch);
} }
} catch (error) { } catch (error: any) {
if (queueItem) { if (queueItem) {
const patch = { const patch = {
status: 'error', status: 'error',
@@ -345,7 +442,7 @@ const useDocumentUploads = ({
) { ) {
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
} }
} catch (error) { } catch (error: any) {
const message = error.message || 'Failed to upload files.'; const message = error.message || 'Failed to upload files.';
queueItems.forEach((item) => { queueItems.forEach((item) => {
if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') { if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') {
@@ -378,8 +475,8 @@ const useDocumentUploads = ({
); );
const handleFileDrop = useCallback( const handleFileDrop = useCallback(
async (dataTransfer, targetFolderId) => { async (dataTransfer: DataTransfer, targetFolderId?: FolderId) => {
let extracted; let extracted: FileEntry[];
try { try {
extracted = await extractFilesFromDataTransfer(dataTransfer); extracted = await extractFilesFromDataTransfer(dataTransfer);
} catch (error) { } catch (error) {
@@ -393,7 +490,7 @@ const useDocumentUploads = ({
); );
const handleFileSelection = useCallback( const handleFileSelection = useCallback(
async (files, targetFolderId) => { async (files?: FileList | null, targetFolderId?: FolderId) => {
const entries = mapFilesToEntries(files); const entries = mapFilesToEntries(files);
await uploadFileEntries(entries, targetFolderId); await uploadFileEntries(entries, targetFolderId);
}, },
@@ -434,7 +531,7 @@ const useDocumentUploads = ({
resetUploadsState, resetUploadsState,
uploadQueue, uploadQueue,
clearUploadQueue, clearUploadQueue,
}; } satisfies UseDocumentUploadsResult;
}; };
export default useDocumentUploads; export default useDocumentUploads;
@@ -1,17 +1,33 @@
import { useCallback, useState } from 'react'; import { Dispatch, SetStateAction, useCallback, useState } from 'react';
const useDocuments = ({ setSearchResults, setFolderContents }) => { interface DocumentLike {
const [documents, setDocuments] = useState([]); 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( const mapDocumentCaches = useCallback(
(mapper) => { (mapper: (doc: DocumentLike) => DocumentLike | undefined) => {
if (typeof mapper !== 'function') { if (typeof mapper !== 'function') {
return; return;
} }
const applyToList = (list) => { const applyToList = (list?: DocumentLike[] | null) => {
let changed = false; let changed = false;
const next = list.map((doc) => { const safeList = Array.isArray(list) ? list : [];
const next = safeList.map((doc) => {
const updated = mapper(doc); const updated = mapper(doc);
if (updated === undefined || updated === doc) { if (updated === undefined || updated === doc) {
return doc; return doc;
@@ -19,7 +35,7 @@ const useDocuments = ({ setSearchResults, setFolderContents }) => {
changed = true; changed = true;
return updated; return updated;
}); });
return changed ? next : list; return changed ? next : safeList;
}; };
setDocuments((prev) => applyToList(prev)); setDocuments((prev) => applyToList(prev));
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
matchPath, matchPath,
useLocation, useLocation,
@@ -57,6 +57,22 @@ const EntryType = Object.freeze({
const noop = () => {}; 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 = ({ const useDocumentsWorkspace = ({
documentsViewMode = 'list', documentsViewMode = 'list',
documentsSortField = DEFAULT_SORT_FIELD, documentsSortField = DEFAULT_SORT_FIELD,
@@ -71,7 +87,7 @@ const useDocumentsWorkspace = ({
onSetSearchIncludeDescendants, onSetSearchIncludeDescendants,
sortRefreshReadyRef, sortRefreshReadyRef,
handleDeskExit, handleDeskExit,
} = {}) => { }: UseDocumentsWorkspaceOptions = {}) => {
const handleDocumentsViewModeChange = onDocumentsViewModeChange || noop; const handleDocumentsViewModeChange = onDocumentsViewModeChange || noop;
const handleDocumentsSortFieldChange = onDocumentsSortFieldChange || noop; const handleDocumentsSortFieldChange = onDocumentsSortFieldChange || noop;
const handleDocumentsSortDirectionToggle = onDocumentsSortDirectionToggle || noop; const handleDocumentsSortDirectionToggle = onDocumentsSortDirectionToggle || noop;
@@ -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 = ({ const useFileDrop = ({
shellRef, shellRef,
@@ -10,7 +27,7 @@ const useFileDrop = ({
defaultFolderName, defaultFolderName,
dragCounterRef, dragCounterRef,
setDropOverlayState, setDropOverlayState,
}) => { }: UseFileDropOptions) => {
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -19,20 +36,20 @@ const useFileDrop = ({
return undefined; return undefined;
} }
const handleDragEnter = (event) => { const handleDragEnter = (event: DragEvent) => {
if (!hasFiles(event)) return; if (!hasFiles(event)) return;
event.preventDefault(); event.preventDefault();
dragCounterRef.current += 1; dragCounterRef.current += 1;
setDropOverlayState({ active: true, folderName: currentFolderName }); setDropOverlayState({ active: true, folderName: currentFolderName });
}; };
const handleDragOver = (event) => { const handleDragOver = (event: DragEvent) => {
if (!hasFiles(event)) return; if (!hasFiles(event)) return;
event.preventDefault(); event.preventDefault();
event.dataTransfer.dropEffect = 'copy'; event.dataTransfer.dropEffect = 'copy';
}; };
const handleDragLeave = (event) => { const handleDragLeave = (event: DragEvent) => {
if (!hasFiles(event)) return; if (!hasFiles(event)) return;
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
if (dragCounterRef.current === 0) { if (dragCounterRef.current === 0) {
@@ -40,7 +57,7 @@ const useFileDrop = ({
} }
}; };
const handleDrop = async (event) => { const handleDrop = async (event: DragEvent) => {
if (!hasFiles(event)) return; if (!hasFiles(event)) return;
event.preventDefault(); event.preventDefault();
dragCounterRef.current = 0; dragCounterRef.current = 0;
@@ -1,6 +1,88 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import type { DragEvent } from 'react';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils'; 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 = ({ const useFolderTreeActions = ({
api, api,
token, token,
@@ -28,9 +110,9 @@ const useFolderTreeActions = ({
setDraggedFolderId, setDraggedFolderId,
isInvalidFolderDrop, isInvalidFolderDrop,
setCreatingFolder, setCreatingFolder,
}) => { }: UseFolderTreeActionsOptions) => {
const moveFolder = useCallback( const moveFolder = useCallback(
async (folderId, targetFolderId) => { async (folderId: FolderKey, targetFolderId: FolderKey | null | undefined) => {
const node = folderNodes.get(folderId); const node = folderNodes.get(folderId);
if (!node) { if (!node) {
setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error'); setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error');
@@ -95,9 +177,11 @@ const useFolderTreeActions = ({
}); });
const refreshTargets = new Set([previousParentKey, targetKey]); const refreshTargets = new Set([previousParentKey, targetKey]);
for (const key of refreshTargets) { await Promise.all(
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }); Array.from(refreshTargets).map((key) =>
} ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }),
),
);
if (selectedFolder === folderId) { if (selectedFolder === folderId) {
await ensureFolderData(folderId, { force: true, prefetchDepth: 1 }); await ensureFolderData(folderId, { force: true, prefetchDepth: 1 });
@@ -110,9 +194,11 @@ const useFolderTreeActions = ({
notifyApiError(error, message); notifyApiError(error, message);
const refreshTargets = new Set([previousParentKey, targetKey]); const refreshTargets = new Set([previousParentKey, targetKey]);
for (const key of refreshTargets) { await Promise.all(
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }); Array.from(refreshTargets).map((key) =>
} ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }),
),
);
} }
}, },
[ [
@@ -128,7 +214,7 @@ const useFolderTreeActions = ({
); );
const loadFolder = useCallback( const loadFolder = useCallback(
async (folderId, { showLoading = true, preserveSearch = false } = {}) => { async (folderId: FolderKey | null | undefined, { showLoading = true, preserveSearch = false }: LoadFolderOptions = {}) => {
const targetId = folderId || 'root'; const targetId = folderId || 'root';
setSelectedFolder(targetId); setSelectedFolder(targetId);
await ensureFolderAncestorsLoaded(targetId); await ensureFolderAncestorsLoaded(targetId);
@@ -170,7 +256,7 @@ const useFolderTreeActions = ({
); );
const selectFolder = useCallback( 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'; const targetId = folderId && folderId !== 'root' ? folderId : 'root';
await ensureFolderAncestorsLoaded(targetId); await ensureFolderAncestorsLoaded(targetId);
@@ -196,7 +282,7 @@ const useFolderTreeActions = ({
); );
const handleFolderRename = useCallback( const handleFolderRename = useCallback(
async (folderId, nextName) => { async (folderId: FolderKey, nextName: string) => {
if (!token) { if (!token) {
setStatusMessage('Log in to rename folders.', 'error'); setStatusMessage('Log in to rename folders.', 'error');
return false; return false;
@@ -257,7 +343,7 @@ const useFolderTreeActions = ({
); );
const handleFolderCreate = useCallback( const handleFolderCreate = useCallback(
async (name) => { async (name: string) => {
if (!token) { if (!token) {
setStatusMessage('Log in to create folders.', 'error'); setStatusMessage('Log in to create folders.', 'error');
return false; return false;
@@ -327,7 +413,7 @@ const useFolderTreeActions = ({
); );
const handleFolderDelete = useCallback( const handleFolderDelete = useCallback(
async (folderId, { showMessage = true, manageLoading = true } = {}) => { async (folderId: FolderKey, { showMessage = true, manageLoading = true }: { showMessage?: boolean; manageLoading?: boolean } = {}) => {
if (!token) { if (!token) {
if (showMessage) { if (showMessage) {
setStatusMessage('Log in to manage folders.', 'error'); 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 node = folderNodes.get(folderId);
const nextExpanded = !(node?.expanded ?? false); const nextExpanded = !(node?.expanded ?? false);
if (nextExpanded) { if (nextExpanded) {
@@ -465,12 +551,12 @@ const useFolderTreeActions = ({
}); });
}, },
onSelect: selectFolder, onSelect: selectFolder,
onDrop: async (event, folderId) => { onDrop: async (event: DragEvent<HTMLElement>, folderId: FolderKey) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
event.currentTarget.classList.remove('is-drop-target'); event.currentTarget.classList.remove('is-drop-target');
let folderIds = []; let folderIds: FolderId[] = [];
try { try {
const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list'); const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list');
if (rawFolderList) { if (rawFolderList) {
@@ -523,7 +609,7 @@ const useFolderTreeActions = ({
return; return;
} }
let docIds = []; let docIds: FolderId[] = [];
try { try {
const raw = event.dataTransfer.getData('application/x-papercrate-doc-list'); const raw = event.dataTransfer.getData('application/x-papercrate-doc-list');
if (raw) { if (raw) {
@@ -560,7 +646,7 @@ const useFolderTreeActions = ({
setDraggedDocumentIds([]); setDraggedDocumentIds([]);
await moveDocumentsToFolder(docIds, folderId); await moveDocumentsToFolder(docIds, folderId);
}, },
onDragOver: (event, folderId) => { onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => {
const folderDragActive = Boolean(draggedFolderId); const folderDragActive = Boolean(draggedFolderId);
if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) { if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) {
return; return;
@@ -579,7 +665,7 @@ const useFolderTreeActions = ({
event.currentTarget.classList.add('is-drop-target'); event.currentTarget.classList.add('is-drop-target');
} }
}, },
onDragLeave: (event) => { onDragLeave: (event: DragEvent<HTMLElement>) => {
event.currentTarget.classList.remove('is-drop-target'); 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 = ({ const useTags = ({
apiClient, apiClient,
@@ -8,8 +36,8 @@ const useTags = ({
tenantIdRef, tenantIdRef,
setActiveTagFilters, setActiveTagFilters,
mapDocumentCaches, mapDocumentCaches,
}) => { }: UseTagsOptions) => {
const [tags, setTags] = useState([]); const [tags, setTags] = useState<TagEntry[]>([]);
const refreshTags = useCallback(async () => { const refreshTags = useCallback(async () => {
const requestTenantId = tenantIdRef.current; const requestTenantId = tenantIdRef.current;
@@ -28,12 +56,12 @@ const useTags = ({
}, [apiClient, notifyApiError, tenantIdRef]); }, [apiClient, notifyApiError, tenantIdRef]);
const handleTagUpdate = useCallback( const handleTagUpdate = useCallback(
async (tagId, changes) => { async (tagId: string | number | null | undefined, changes: { label?: string; color?: string | null }) => {
if (!tagId) { if (!tagId) {
throw new Error('Missing tag identifier.'); throw new Error('Missing tag identifier.');
} }
const payload = {}; const payload: Record<string, unknown> = {};
if (typeof changes?.label?.trim === 'function') { if (typeof changes?.label?.trim === 'function') {
payload.label = changes.label; payload.label = changes.label;
} }
@@ -60,7 +88,7 @@ const useTags = ({
); );
const handleTagCreate = useCallback( const handleTagCreate = useCallback(
async ({ label, color } = {}) => { async ({ label, color }: { label?: string; color?: string | null } = {}) => {
const payload = tagManager.buildPayload({ label, color }); const payload = tagManager.buildPayload({ label, color });
try { try {
await apiClient.post('/tags', payload); await apiClient.post('/tags', payload);
@@ -76,7 +104,7 @@ const useTags = ({
); );
const handleTagDelete = useCallback( const handleTagDelete = useCallback(
async (tagId) => { async (tagId: string | number | null | undefined) => {
if (!tagId) { if (!tagId) {
throw new Error('Missing tag identifier.'); throw new Error('Missing tag identifier.');
} }
@@ -85,7 +113,7 @@ const useTags = ({
await apiClient.delete(`/tags/${tagId}`); await apiClient.delete(`/tags/${tagId}`);
setActiveTagFilters((prev) => prev.filter((id) => id !== tagId)); setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
const stripTagFromDoc = (doc) => { const stripTagFromDoc = (doc: any) => {
if (!doc || !Array.isArray(doc.tags)) { if (!doc || !Array.isArray(doc.tags)) {
return doc; return doc;
} }
@@ -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 = ({ const useTenantManager = ({
apiClient, apiClient,
@@ -15,9 +44,9 @@ const useTenantManager = ({
navigate, navigate,
tokenRef, tokenRef,
tenantIdRef, tenantIdRef,
}) => { }: UseTenantManagerOptions) => {
const handleTenantSelect = useCallback( const handleTenantSelect = useCallback(
async (tenantOption, { refreshOnly = false } = {}) => { async (tenantOption: TenantOption | null, { refreshOnly = false }: { refreshOnly?: boolean } = {}) => {
const requestedTenantId = tenantOption?.id ?? null; const requestedTenantId = tenantOption?.id ?? null;
if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) { if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
return; return;
-33
View File
@@ -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;
+50
View File
@@ -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;
-133
View File
@@ -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;
+208
View File
@@ -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;
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Papercrate</title> <title>Papercrate</title>
</head> </head>
<body> <body>
@@ -8,6 +8,11 @@ import { AppStateProvider } from './app/appState';
import AppRouter from './app/AppRouter'; import AppRouter from './app/AppRouter';
const container = document.getElementById('app'); const container = document.getElementById('app');
if (!container) {
throw new Error('App root element #app not found');
}
const root = createRoot(container); const root = createRoot(container);
root.render( root.render(
<AppStateProvider> <AppStateProvider>
@@ -1,11 +1,40 @@
import React, { useEffect, useState } from 'react'; 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; if (!status) return null;
return <div className={`status-banner ${status.variant}`}>{status.message}</div>; 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, status,
tenantSelection, tenantSelection,
onSelectTenant, onSelectTenant,
@@ -41,7 +70,7 @@ const LoginView = ({
onSignup(username); onSignup(username);
}; };
const handleSubmit = (event) => { const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (!passkeySupported || passkeyLoading || magicLoginPending || !username.trim()) { if (!passkeySupported || passkeyLoading || magicLoginPending || !username.trim()) {
return; return;
@@ -57,7 +86,7 @@ const LoginView = ({
<div className="login-card__selection"> <div className="login-card__selection">
<p>Select a tenant to finish signing in.</p> <p>Select a tenant to finish signing in.</p>
<div className="login-card__tenant-list"> <div className="login-card__tenant-list">
{tenantSelection.tenants.map((tenant) => ( {tenantSelection?.tenants?.map((tenant) => (
<button <button
key={tenant.id} key={tenant.id}
type="button" type="button"
@@ -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 DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { DownloadIcon } from '../ui/icons'; 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 = ({ const DocumentViewerLayout = ({
document, document,
previewEntry, previewEntry,
summaryProps, summaryProps = {},
metadataPayload, metadataPayload,
contentTabConfig, contentTabConfig,
resetKey, resetKey,
@@ -14,7 +59,7 @@ const DocumentViewerLayout = ({
infoPanelProps = {}, infoPanelProps = {},
previewLoadingMessage = 'Preparing preview…', previewLoadingMessage = 'Preparing preview…',
layoutMode = 'split', layoutMode = 'split',
}) => { }: DocumentViewerLayoutProps): JSX.Element => {
const isStacked = layoutMode === 'stacked'; const isStacked = layoutMode === 'stacked';
const previewContent = useMemo(() => { const previewContent = useMemo(() => {
@@ -103,7 +148,7 @@ const DocumentViewerLayout = ({
const resolvedDefaultTabId = isStacked ? 'preview' : defaultTabId; const resolvedDefaultTabId = isStacked ? 'preview' : defaultTabId;
const summaryPlacement = 'tabs'; const summaryPlacement = 'tabs';
const tabsPlacement = isStacked ? 'bottom' : 'top'; const tabsPlacement = 'bottom';
const summaryLayout = 'compact'; const summaryLayout = 'compact';
const detailsPane = ( const detailsPane = (
@@ -5,12 +5,13 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
DownloadIcon, DownloadIcon,
CloseIcon, CloseIcon,
IconZoomInArea, IconZoomInArea,
DetailPanelCollapseIcon, IconX,
WindowMaximizeIcon, WindowMaximizeIcon,
} from '../ui/icons'; } from '../ui/icons';
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
@@ -18,6 +19,7 @@ import {
buildCorrespondentOptions, buildCorrespondentOptions,
sortCorrespondents, sortCorrespondents,
} from '../documents/DocumentSummarySection'; } from '../documents/DocumentSummarySection';
import type { DocumentSummarySectionProps } from '../documents/DocumentSummarySection';
import { extractDocumentMetadataPayload } from '../documents/documentMetadata'; import { extractDocumentMetadataPayload } from '../documents/documentMetadata';
import { createDocumentActionState } from '../documents/documentActions'; import { createDocumentActionState } from '../documents/documentActions';
import { resolveDocumentAssetUrl } from '../asset_manager'; import { resolveDocumentAssetUrl } from '../asset_manager';
@@ -28,6 +30,44 @@ import DocumentViewerLayout from './DocumentViewerLayout';
import useViewerLayoutMode from './useViewerLayoutMode'; import useViewerLayoutMode from './useViewerLayoutMode';
import { usePanelResizeBindings } from '../app/PanelManagerContext'; 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 = ({ export const createDocumentViewerHeaderActions = ({
document, document,
@@ -74,7 +114,7 @@ export const createDocumentViewerHeaderActions = ({
); );
}; };
const DocumentViewerPanel = ({ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
document, document,
previewEntry, previewEntry,
hydrateDocument, hydrateDocument,
@@ -239,7 +279,7 @@ const DocumentViewerPanel = ({
} }
}, [hydrateDocument, document?.id]); }, [hydrateDocument, document?.id]);
const panelRef = useRef(null); const panelRef = useRef<HTMLDivElement | HTMLFormElement | HTMLElement | null>(null);
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id); const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
const { const {
@@ -366,7 +406,7 @@ const DocumentViewerPanel = ({
aria-label="Close detail panel" aria-label="Close detail panel"
title="Close detail panel" title="Close detail panel"
> >
<DetailPanelCollapseIcon /> <IconX />
</button> </button>
) )
: null; : null;

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