diff --git a/frontend/babel.config.js b/frontend/babel.config.js index 9cc4abc..86e4d21 100644 --- a/frontend/babel.config.js +++ b/frontend/babel.config.js @@ -12,5 +12,6 @@ module.exports = { runtime: 'automatic', }, ], + '@babel/preset-typescript', ], }; diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index fc2c17b..9109a77 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -2,6 +2,10 @@ import js from '@eslint/js'; import pluginReact from 'eslint-plugin-react'; import pluginReactHooks from 'eslint-plugin-react-hooks'; import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import tsPluginImport from '@typescript-eslint/eslint-plugin'; + +const tsPlugin = tsPluginImport.default ?? tsPluginImport; const sharedRules = { ...js.configs.recommended.rules, @@ -42,6 +46,7 @@ export default [ plugins: { react: pluginReact, 'react-hooks': pluginReactHooks, + '@typescript-eslint': tsPlugin, }, settings: { react: { @@ -50,4 +55,32 @@ export default [ }, rules: sharedRules, }, + { + files: ['src/**/*.{ts,tsx}'], + languageOptions: { + ...sharedLanguageOptions, + parser: tsParser, + }, + plugins: { + react: pluginReact, + 'react-hooks': pluginReactHooks, + '@typescript-eslint': tsPlugin, + }, + settings: { + react: { + version: 'detect', + }, + }, + rules: { + ...sharedRules, + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + }, + }, ]; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7284faf..5769f8a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,7 +19,10 @@ "@babel/core": "^7.28.5", "@babel/preset-env": "^7.28.5", "@babel/preset-react": "^7.28.5", + "@babel/preset-typescript": "^7.28.5", "@svgr/webpack": "^8.1.0", + "@typescript-eslint/eslint-plugin": "^8.46.4", + "@typescript-eslint/parser": "^8.18.1", "babel-loader": "^10.0.0", "css-loader": "^7.1.2", "dotenv": "^17.2.3", @@ -29,6 +32,7 @@ "globals": "^16.5.0", "html-webpack-plugin": "^5.6.4", "style-loader": "^4.0.0", + "typescript": "^5.7.3", "webpack": "^5.102.1", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.2" @@ -183,18 +187,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz", - "integrity": "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz", + "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.3", + "@babel/traverse": "^7.28.5", "semver": "^6.3.1" }, "engines": { @@ -295,14 +299,14 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", - "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -1541,14 +1545,14 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz", - "integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz", + "integrity": "sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1" @@ -1759,9 +1763,9 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", - "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1769,7 +1773,7 @@ "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.27.1" + "@babel/plugin-transform-typescript": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -2303,6 +2307,44 @@ "dev": true, "license": "MIT" }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -2875,6 +2917,377 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", + "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/type-utils": "8.46.4", + "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.46.4", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", + "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", + "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.4", + "@typescript-eslint/types": "^8.46.4", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", + "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", + "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", + "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/utils": "8.46.4", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", + "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", + "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.4", + "@typescript-eslint/tsconfig-utils": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", + "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", + "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.4", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -5413,6 +5826,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -5454,6 +5884,16 @@ "node": ">= 4.9.1" } }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/faye-websocket": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", @@ -5845,6 +6285,13 @@ "dev": true, "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -7151,6 +7598,16 @@ "dev": true, "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -7954,6 +8411,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -8303,6 +8781,17 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -8316,6 +8805,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -9432,6 +9945,19 @@ "tslib": "2" } }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -9544,6 +10070,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index af8a8c7..9c76e55 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "webpack serve --mode development --open", "build": "webpack --mode production", - "lint": "eslint src --ext .js,.jsx", + "lint": "eslint src --ext .js,.jsx,.ts,.tsx", "test:engine": "node --test tests/workspaceEngine.test.js" }, "dependencies": { @@ -21,7 +21,10 @@ "@babel/core": "^7.28.5", "@babel/preset-env": "^7.28.5", "@babel/preset-react": "^7.28.5", + "@babel/preset-typescript": "^7.28.5", "@svgr/webpack": "^8.1.0", + "@typescript-eslint/eslint-plugin": "^8.46.4", + "@typescript-eslint/parser": "^8.18.1", "babel-loader": "^10.0.0", "css-loader": "^7.1.2", "dotenv": "^17.2.3", @@ -31,6 +34,7 @@ "globals": "^16.5.0", "html-webpack-plugin": "^5.6.4", "style-loader": "^4.0.0", + "typescript": "^5.7.3", "webpack": "^5.102.1", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.2" diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.tsx similarity index 98% rename from frontend/src/app/AppLayout.jsx rename to frontend/src/app/AppLayout.tsx index 87e1c85..8fdd324 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.tsx @@ -7,7 +7,7 @@ import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace'; import { useDocumentsPreferences } from './useDocumentsPreferences'; import SettingsRoute from './SettingsRoute'; -const AppLayout = () => { +const AppLayout: React.FC = () => { const documentsPreferences = useDocumentsPreferences(); const { appStatus, diff --git a/frontend/src/app/AppRouter.jsx b/frontend/src/app/AppRouter.tsx similarity index 100% rename from frontend/src/app/AppRouter.jsx rename to frontend/src/app/AppRouter.tsx diff --git a/frontend/src/app/DocumentsLayout.jsx b/frontend/src/app/DocumentsLayout.tsx similarity index 69% rename from frontend/src/app/DocumentsLayout.jsx rename to frontend/src/app/DocumentsLayout.tsx index 9c9c78f..740efe0 100644 --- a/frontend/src/app/DocumentsLayout.jsx +++ b/frontend/src/app/DocumentsLayout.tsx @@ -1,9 +1,14 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import Sidebar from '../sidebar/Sidebar'; import { useSidebarContext } from '../sidebar/SidebarContext'; import { usePanelManager } from './PanelManagerContext'; -const DocumentsLayout = ({ sidebarProps, children }) => { +interface DocumentsLayoutProps { + sidebarProps?: Record; + children?: ReactNode; +} + +const DocumentsLayout: React.FC = ({ sidebarProps = {}, children }) => { const { collapsed } = useSidebarContext(); const { sidebarSuppressed } = usePanelManager(); const sidebarHidden = collapsed || sidebarSuppressed; diff --git a/frontend/src/app/DocumentsRoute.jsx b/frontend/src/app/DocumentsRoute.tsx similarity index 94% rename from frontend/src/app/DocumentsRoute.jsx rename to frontend/src/app/DocumentsRoute.tsx index 81f15be..0a5dbda 100644 --- a/frontend/src/app/DocumentsRoute.jsx +++ b/frontend/src/app/DocumentsRoute.tsx @@ -8,7 +8,9 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail'; import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext'; import { PanelManagerProvider, usePanelManager } from './PanelManagerContext'; -const DocumentsRouteContent = () => { +type Breadcrumb = { id?: string | number; name?: string; label?: string; title?: string }; + +const DocumentsRouteContent: React.FC = () => { const { sidebarProps, documentsTableProps, @@ -64,7 +66,7 @@ const DocumentsRouteContent = () => { navigate(target); }, [navigate, parentBreadcrumb]); - const handleHeaderBreadcrumbClick = useCallback((crumb) => { + const handleHeaderBreadcrumbClick = useCallback((crumb: Breadcrumb) => { if (!crumb || !crumb.id) { return; } @@ -121,7 +123,7 @@ const DocumentsRouteContent = () => { const header = surface.header || null; - let headerTitle = null; + let headerTitle: React.ReactNode = null; if (header) { const breadcrumbEntries = Array.isArray(header.breadcrumbs) ? header.breadcrumbs.filter(Boolean) : []; const lastIndex = breadcrumbEntries.length - 1; @@ -176,7 +178,7 @@ const DocumentsRouteContent = () => { ); }; -const DocumentsRoute = () => ( +const DocumentsRoute: React.FC = () => ( diff --git a/frontend/src/app/DropOverlay.jsx b/frontend/src/app/DropOverlay.tsx similarity index 61% rename from frontend/src/app/DropOverlay.jsx rename to frontend/src/app/DropOverlay.tsx index 65cc84a..16ef85c 100644 --- a/frontend/src/app/DropOverlay.jsx +++ b/frontend/src/app/DropOverlay.tsx @@ -1,6 +1,11 @@ import React from 'react'; -const DropOverlay = ({ active, folderName }) => ( +interface DropOverlayProps { + active?: boolean; + folderName?: string | null; +} + +const DropOverlay: React.FC = ({ active = false, folderName }) => (
Drop files to upload to {folderName || 'this location'} diff --git a/frontend/src/app/LoginRoute.jsx b/frontend/src/app/LoginRoute.tsx similarity index 96% rename from frontend/src/app/LoginRoute.jsx rename to frontend/src/app/LoginRoute.tsx index 7bf9ab3..68d6c42 100644 --- a/frontend/src/app/LoginRoute.jsx +++ b/frontend/src/app/LoginRoute.tsx @@ -11,11 +11,31 @@ import { } from '../utils/webauthn'; import { api, useAppDispatch, useAppState } from './appState'; -const LoginRoute = () => { - const { status: appStatus, tenantSelection } = useAppState(); +type StatusVariant = 'info' | 'success' | 'error'; + +interface StatusMessage { + message: string; + variant: StatusVariant; +} + +interface TenantOption { + id?: string | number | null; + name?: string | null; +} + +interface TenantSelectionState { + selectionToken?: string | null; + tenants?: TenantOption[]; +} + + +const LoginRoute: React.FC = () => { + const appState = useAppState(); + const { status: appStatus } = appState; + const tenantSelection = (appState.tenantSelection ?? null) as TenantSelectionState | null; const appDispatch = useAppDispatch(); const location = useLocation(); - const [status, setStatus] = useState(null); + const [status, setStatus] = useState(null); const [selectingTenantId, setSelectingTenantId] = useState(null); const passkeySupported = isWebAuthnAvailable(); const [passkeyLoading, setPasskeyLoading] = useState(false); diff --git a/frontend/src/app/PanelManagerContext.js b/frontend/src/app/PanelManagerContext.tsx similarity index 56% rename from frontend/src/app/PanelManagerContext.js rename to frontend/src/app/PanelManagerContext.tsx index f0b38a1..b197a7c 100644 --- a/frontend/src/app/PanelManagerContext.js +++ b/frontend/src/app/PanelManagerContext.tsx @@ -1,4 +1,5 @@ import React, { + ReactNode, createContext, useCallback, useContext, @@ -7,48 +8,85 @@ import React, { useRef, useState, } from 'react'; +import type { CSSProperties, PointerEvent as ReactPointerEvent, RefObject } from 'react'; import { useSidebarContext } from '../sidebar/SidebarContext'; -const PanelManagerContext = createContext(null); +type PanelKey = 'sidebar' | 'detail'; -const PANEL_LIMITS = { +interface PanelLimits { + maxRatio: number; + minPx: number; +} + +interface SetPanelWidthOptions { + commit?: boolean; + log?: boolean; +} + +interface PanelManagerContextValue { + sidebarWidth: number; + detailWidth: number; + sidebarSuppressed: boolean; + resizingPanel: PanelKey | null; + setPanelWidth: (panel: PanelKey, width: number, options?: SetPanelWidthOptions) => number; + startPanelResize: (panel: PanelKey) => void; + stopPanelResize: () => void; + getPanelWidth: (panel: PanelKey) => number; + setDetailActive: (isOpen: boolean) => void; + closeDetailPanel: () => void; + collapseSidebar: () => void; + expandSidebar: () => void; + registerDetailCloseHandler: (handler?: (() => void) | null) => void; + detailPanelOpen: boolean; +} + +type PanelResizeBindings = { + panelStyle?: CSSProperties; + handleProps: { + onPointerDown?: (event: ReactPointerEvent) => void; + }; + isPanelResizing: boolean; +}; + +const PanelManagerContext = createContext(null); + +const PANEL_LIMITS: Record = { sidebar: { - minRatio: 1 / 6, - maxRatio: 1 / 4, - minPx: 240, + maxRatio: 1 / 3, + minPx: 280, }, detail: { - minRatio: 1 / 4, - maxRatio: 3 / 4, + maxRatio: 2 / 3, minPx: 320, }, }; -const STORAGE_KEYS = { +const STORAGE_KEYS: Record = { sidebar: 'papercrate_sidebar_width', detail: 'papercrate_detail_width', }; -const MINIMAL_FREE_RATIO = 1 / 3; +const DEFAULT_SIDEBAR_WIDTH = 320; +const DEFAULT_DETAIL_WIDTH = 420; -const clampPanelWidth = (panel, value) => { +const MINIMAL_FREE_RATIO = 1 / 3; +const SIDEBAR_SOLO_THRESHOLD = 1 / 2; +const MINIMUM_MAIN_CONTENT_WIDTH = 160; + +const clampPanelWidth = (panel: PanelKey, value: number): number => { const numeric = Number(value); const limits = PANEL_LIMITS[panel]; - if (!limits) { - return numeric; - } const viewport = window.innerWidth; - const ratioMin = Math.round(viewport * limits.minRatio); + const minLimit = Math.max(0, limits.minPx); const ratioMax = Math.round(viewport * limits.maxRatio); - const minLimit = Math.max(0, Math.max(ratioMin, limits.minPx)); - const rawMax = Math.max(minLimit, ratioMax); - const maxAllowed = Math.min(rawMax, viewport - 160); - const targetMax = Math.max(minLimit, maxAllowed); - return Math.min(Math.max(numeric, minLimit), targetMax); + const rawMax = Math.max(ratioMax, minLimit); + const maxAllowed = Math.min(rawMax, viewport - MINIMUM_MAIN_CONTENT_WIDTH); + const targetMax = Math.min(viewport, Math.max(minLimit, maxAllowed)); + return Math.min(Math.max(numeric, minLimit), Math.max(0, targetMax)); }; -const readStoredWidth = (panel, fallback) => { - const raw = window?.localStorage?.getItem(STORAGE_KEYS[panel]); +const readStoredWidth = (panel: PanelKey, fallback: number): number => { + const raw = window.localStorage.getItem(STORAGE_KEYS[panel]); if (!raw) { return fallback; } @@ -56,22 +94,26 @@ const readStoredWidth = (panel, fallback) => { return Number.isFinite(parsed) ? parsed : fallback; }; -const persistWidth = (panel, value) => { - window?.localStorage?.setItem(STORAGE_KEYS[panel], String(Math.round(value))); +const persistWidth = (panel: PanelKey, value: number): void => { + window.localStorage.setItem(STORAGE_KEYS[panel], String(Math.round(value))); }; -const applyPanelWidthToRoot = (panel, width) => { +const applyPanelWidthToRoot = (panel: PanelKey, width: number): void => { if (!Number.isFinite(width)) { return; } const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width'; - document?.documentElement?.style.setProperty(varName, `${width}px`); + document.documentElement.style.setProperty(varName, `${width}px`); }; -export const PanelManagerProvider = ({ children }) => { +interface PanelManagerProviderProps { + children: ReactNode; +} + +export const PanelManagerProvider: React.FC = ({ children }) => { const { collapsed, setCollapsed } = useSidebarContext(); - const initialSidebarWidth = readStoredWidth('sidebar', 320); - const initialDetailWidth = readStoredWidth('detail', 420); + const initialSidebarWidth = readStoredWidth('sidebar', DEFAULT_SIDEBAR_WIDTH); + const initialDetailWidth = readStoredWidth('detail', DEFAULT_DETAIL_WIDTH); const [sidebarWidth, setSidebarWidthState] = useState(() => clampPanelWidth('sidebar', initialSidebarWidth)); const [detailWidth, setDetailWidthState] = useState(() => clampPanelWidth('detail', initialDetailWidth)); @@ -81,6 +123,7 @@ export const PanelManagerProvider = ({ children }) => { const detailCloseHandlerRef = useRef(null); const panelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth }); + const preferredPanelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth }); const closeDetailPanel = useCallback(() => { const handler = detailCloseHandlerRef.current; @@ -97,15 +140,12 @@ export const PanelManagerProvider = ({ children }) => { applyPanelWidthToRoot('detail', detailWidth); }, [detailWidth]); - useEffect(() => { - persistWidth('sidebar', sidebarWidth); - }, [sidebarWidth]); - - useEffect(() => { - persistWidth('detail', detailWidth); - }, [detailWidth]); - - const logPanelState = useCallback((panel, action, value) => { + const handlePanelLayoutChange = useCallback(( + panel: PanelKey, + action: 'opened' | 'closed' | 'resized', + value?: number, + { detailOpen }: { detailOpen?: boolean } = {}, + ) => { const viewportWidth = window.innerWidth; const sidebarWidth = panelWidthsRef.current.sidebar; const detailWidth = panelWidthsRef.current.detail; @@ -113,6 +153,7 @@ export const PanelManagerProvider = ({ children }) => { const freeRatio = viewportWidth > 0 ? freeSpace / viewportWidth : 0; const meetsThreshold = freeRatio >= MINIMAL_FREE_RATIO; + const effectiveDetailOpen = detailOpen ?? detailPanelOpen; if (panel === 'detail' && !collapsed) { if (action === 'opened' || action === 'resized') { @@ -122,7 +163,7 @@ export const PanelManagerProvider = ({ children }) => { } } - if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && detailPanelOpen) { + if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && effectiveDetailOpen) { closeDetailPanel(); } @@ -138,18 +179,17 @@ export const PanelManagerProvider = ({ children }) => { minimalFreeRatio: MINIMAL_FREE_RATIO, meetsThreshold, }); - }, [collapsed, sidebarSuppressed, closeDetailPanel, detailPanelOpen]); + }, [collapsed, closeDetailPanel, detailPanelOpen]); const collapseSidebar = useCallback(() => { if (!collapsed) { setCollapsed(true); - setSidebarSuppressed(false); - logPanelState('sidebar', 'closed'); + handlePanelLayoutChange('sidebar', 'closed'); } - }, [collapsed, setCollapsed, logPanelState]); + }, [collapsed, setCollapsed, handlePanelLayoutChange]); const setPanelWidth = useCallback( - (panel, width, commit = true) => { + (panel: PanelKey, width: number, { commit = true, log = true }: SetPanelWidthOptions = {}) => { const clamped = clampPanelWidth(panel, width); if (!Number.isFinite(clamped)) { return panelWidthsRef.current[panel]; @@ -162,34 +202,78 @@ export const PanelManagerProvider = ({ children }) => { panelWidthsRef.current[panel] = clamped; applyPanelWidthToRoot(panel, clamped); if (commit) { + preferredPanelWidthsRef.current[panel] = clamped; persistWidth(panel, clamped); } - logPanelState(panel, 'resized', clamped); + if (log) { + handlePanelLayoutChange(panel, 'resized', clamped); + } return clamped; }, - [collapsed, logPanelState, sidebarSuppressed], + [handlePanelLayoutChange], ); - const registerDetailCloseHandler = useCallback((handler = null) => { - detailCloseHandlerRef.current = typeof handler === 'function' ? handler : null; + const resetSidebarPreferredWidth = useCallback(() => { + if (preferredPanelWidthsRef.current.sidebar === DEFAULT_SIDEBAR_WIDTH) { + return; + } + preferredPanelWidthsRef.current.sidebar = DEFAULT_SIDEBAR_WIDTH; + persistWidth('sidebar', DEFAULT_SIDEBAR_WIDTH); + }, []); + + const clampPanelsWithinViewport = useCallback(() => { + const viewportWidth = Math.max(0, Number(window.innerWidth) || 0); + if (viewportWidth === 0) { + return; + } + + const desiredSidebarWidth = preferredPanelWidthsRef.current.sidebar; + const desiredDetailWidth = preferredPanelWidthsRef.current.detail; + + let sidebarDisplayWidth = Math.min(desiredSidebarWidth, viewportWidth); + let remainingWidth = Math.max(0, viewportWidth - sidebarDisplayWidth); + let detailDisplayWidth = Math.min(desiredDetailWidth, remainingWidth); + if (detailPanelOpen && sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) { + sidebarDisplayWidth = 0; + detailDisplayWidth = Math.min(desiredDetailWidth || viewportWidth, viewportWidth); + } else if (sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) { + sidebarDisplayWidth = viewportWidth; + detailDisplayWidth = 0; + resetSidebarPreferredWidth(); + } + + panelWidthsRef.current.sidebar = sidebarDisplayWidth; + panelWidthsRef.current.detail = detailDisplayWidth; + + setSidebarWidthState((prev) => (prev === sidebarDisplayWidth ? prev : sidebarDisplayWidth)); + setDetailWidthState((prev) => (prev === detailDisplayWidth ? prev : detailDisplayWidth)); + }, [detailPanelOpen, resetSidebarPreferredWidth]); + + useEffect(() => { + clampPanelsWithinViewport(); + window.addEventListener('resize', clampPanelsWithinViewport); + return () => window.removeEventListener('resize', clampPanelsWithinViewport); + }, [clampPanelsWithinViewport]); + + const registerDetailCloseHandler = useCallback((handler: (() => void) | null = null) => { + detailCloseHandlerRef.current = handler ?? null; }, []); const setDetailActive = useCallback( (isOpen) => { setDetailPanelOpen(Boolean(isOpen)); - logPanelState('detail', isOpen ? 'opened' : 'closed'); + handlePanelLayoutChange('detail', isOpen ? 'opened' : 'closed'); }, - [logPanelState], + [handlePanelLayoutChange], ); const expandSidebar = useCallback(() => { if (collapsed) { setCollapsed(false); } - setSidebarSuppressed(false); - logPanelState('sidebar', 'opened'); - }, [collapsed, setCollapsed, logPanelState]); + handlePanelLayoutChange('sidebar', 'opened'); + }, [collapsed, setCollapsed, handlePanelLayoutChange]); const startPanelResize = useCallback((panel) => { setResizingPanel(panel); @@ -247,7 +331,10 @@ export const usePanelManager = () => { return context; }; -export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true } = {}) => { +export const usePanelResizeBindings = ( + panel: PanelKey, + { panelRef = null, enabled = true }: { panelRef?: RefObject | null; enabled?: boolean } = {}, +): PanelResizeBindings => { const { sidebarWidth, detailWidth, @@ -277,7 +364,7 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true useEffect(() => () => teardownListeners(), [teardownListeners]); const handlePointerDown = useCallback( - (event) => { + (event: ReactPointerEvent) => { if (!enabled || !panelRef?.current) { return; } @@ -294,18 +381,18 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true event.currentTarget?.setPointerCapture?.(pointerId); let lastWidth = startWidth; - const handlePointerMove = (moveEvent) => { + const handlePointerMove = (moveEvent: PointerEvent) => { if (moveEvent.pointerId !== pointerId) { return; } const delta = panel === 'sidebar' ? moveEvent.clientX - startX : startX - moveEvent.clientX; - lastWidth = setPanelWidth(panel, startWidth + delta, false); + lastWidth = setPanelWidth(panel, startWidth + delta, { commit: false }); latestWidthRef.current = lastWidth; }; - const handlePointerUp = (upEvent) => { + const handlePointerUp = (upEvent: PointerEvent) => { if (upEvent.pointerId !== pointerId) { return; } diff --git a/frontend/src/app/SettingsRoute.jsx b/frontend/src/app/SettingsRoute.tsx similarity index 94% rename from frontend/src/app/SettingsRoute.jsx rename to frontend/src/app/SettingsRoute.tsx index ce7cc6c..00a16ee 100644 --- a/frontend/src/app/SettingsRoute.jsx +++ b/frontend/src/app/SettingsRoute.tsx @@ -6,7 +6,12 @@ import useCapabilitySets from '../settings/useCapabilitySets'; import useCapabilities from '../settings/useCapabilities'; import { api } from './appState'; -const SettingsRoute = ({ open = true, onClose }) => { +interface SettingsRouteProps { + open?: boolean; + onClose?: () => void; +} + +const SettingsRoute: React.FC = ({ open = true, onClose }) => { const { token, notifyApiError, @@ -19,7 +24,7 @@ const SettingsRoute = ({ open = true, onClose }) => { refreshPasskeys, registerPasskey, revokePasskey, - } = useAppShell(); + } = useAppShell() as Record; const { tokens, diff --git a/frontend/src/app/UploadQueueOverlay.jsx b/frontend/src/app/UploadQueueOverlay.tsx similarity index 90% rename from frontend/src/app/UploadQueueOverlay.jsx rename to frontend/src/app/UploadQueueOverlay.tsx index 40af83c..7fbd272 100644 --- a/frontend/src/app/UploadQueueOverlay.jsx +++ b/frontend/src/app/UploadQueueOverlay.tsx @@ -1,4 +1,5 @@ -import React, { useMemo, useState, useEffect } from 'react'; +import { useMemo, useState, useEffect } from 'react'; +import type { JSX } from 'react'; import { useNavigate } from 'react-router-dom'; import { CloseIcon, @@ -12,7 +13,23 @@ import { } from '../ui/icons'; import PanelHeader from '../ui/PanelHeader'; -const STATUS_META = { +type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error' | (string & {}); + +interface UploadQueueItem { + id: string | number; + name: string; + status: UploadStatus; + error?: string | null; + document?: { id?: string | number; title?: string }; + conflictDocumentId?: string | number; +} + +interface UploadQueueOverlayProps { + queue?: UploadQueueItem[]; + onClearQueue?: () => void; +} + +const STATUS_META: Record = { pending: { label: 'Queued', tone: 'muted', @@ -40,7 +57,7 @@ const STATUS_META = { }, }; -const UploadQueueOverlay = ({ queue = [], onClearQueue }) => { +const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProps): JSX.Element | null => { const navigate = useNavigate(); const [collapsed, setCollapsed] = useState(false); const [dismissed, setDismissed] = useState(false); diff --git a/frontend/src/app/appLayoutUtils.js b/frontend/src/app/appLayoutUtils.ts similarity index 100% rename from frontend/src/app/appLayoutUtils.js rename to frontend/src/app/appLayoutUtils.ts diff --git a/frontend/src/app/appState.js b/frontend/src/app/appState.tsx similarity index 73% rename from frontend/src/app/appState.js rename to frontend/src/app/appState.tsx index cc7f6dd..ea15f34 100644 --- a/frontend/src/app/appState.js +++ b/frontend/src/app/appState.tsx @@ -1,10 +1,52 @@ import React, { useContext, useEffect, useMemo, useReducer } from 'react'; import api from '../lib/api'; -const storage = window.sessionStorage; +type Tenant = Record | null; + +interface TenantSelection { + selectionToken: string; + tenants: Tenant[]; +} + +type AppStatus = + | 'logged-out' + | 'authenticating' + | 'authenticated' + | 'selecting-tenant' + | 'bootstrapping' + | 'ready'; + +interface AppState { + status: AppStatus; + token: string; + error: string | null; + isRefreshing: boolean; + tenantSelection: TenantSelection | null; + tenant: Tenant; + tenants: Tenant[]; +} + +type AppAction = + | { type: 'LOGIN_REQUEST' } + | { type: 'LOGIN_SUCCESS'; token: string; tenant?: Tenant } + | { type: 'LOGIN_FAILURE'; error?: string | null } + | { type: 'TENANT_SELECTION_REQUIRED'; selectionToken: string; tenants: Tenant[] } + | { type: 'CLEAR_TENANT_SELECTION' } + | { type: 'LOGOUT_SUCCESS' } + | { type: 'BOOTSTRAP_START' } + | { type: 'BOOTSTRAP_SUCCESS' } + | { type: 'BOOTSTRAP_FAILURE'; error?: string | null } + | { type: 'TOKEN_REFRESH_START' } + | { type: 'TOKEN_REFRESH_SUCCESS'; token: string; tenant?: Tenant } + | { type: 'TOKEN_REFRESH_FAILURE'; error?: string | null } + | { type: 'LOGOUT' } + | { type: 'RESET_ERROR' } + | { type: 'SET_TENANTS'; tenants: Tenant[] }; + +const storage = typeof window !== 'undefined' ? window.sessionStorage : null; const STORED_TOKEN = storage?.getItem('papercrate_token') ?? ''; -let STORED_TENANT = null; +let STORED_TENANT: Tenant = null; if (storage) { try { @@ -21,7 +63,7 @@ if (STORED_TOKEN) { api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`; } -const initialAppState = { +const initialAppState: AppState = { status: STORED_TOKEN ? 'authenticated' : 'logged-out', token: STORED_TOKEN, error: null, @@ -31,10 +73,10 @@ const initialAppState = { tenants: [], }; -const AppStateContext = React.createContext(null); -const AppDispatchContext = React.createContext(null); +const AppStateContext = React.createContext(null); +const AppDispatchContext = React.createContext | null>(null); -const appStateReducer = (state, action) => { +const appStateReducer = (state: AppState, action: AppAction): AppState => { switch (action.type) { case 'LOGIN_REQUEST': return { @@ -52,14 +94,14 @@ const appStateReducer = (state, action) => { token: action.token, error: null, tenantSelection: null, - tenant: action.tenant || null, + tenant: action.tenant ?? null, tenants: state.tenants, }; case 'LOGIN_FAILURE': return { status: 'logged-out', token: '', - error: action.error || null, + error: action.error ?? null, isRefreshing: false, tenantSelection: null, tenant: null, @@ -89,6 +131,7 @@ const appStateReducer = (state, action) => { tenants: [], }; case 'LOGOUT_SUCCESS': + case 'LOGOUT': return { status: 'logged-out', token: '', @@ -103,7 +146,7 @@ const appStateReducer = (state, action) => { case 'BOOTSTRAP_SUCCESS': return { ...state, status: 'ready', error: null }; case 'BOOTSTRAP_FAILURE': - return { ...state, status: 'authenticated', error: action.error || null }; + return { ...state, status: 'authenticated', error: action.error ?? null }; case 'TOKEN_REFRESH_START': return { ...state, isRefreshing: true, error: null }; case 'TOKEN_REFRESH_SUCCESS': @@ -113,24 +156,14 @@ const appStateReducer = (state, action) => { isRefreshing: false, status: state.status === 'logged-out' ? 'authenticated' : state.status, tenantSelection: null, - tenant: action.tenant || state.tenant || null, + tenant: action.tenant ?? state.tenant ?? null, tenants: state.tenants, }; case 'TOKEN_REFRESH_FAILURE': return { status: 'logged-out', token: '', - error: action.error || null, - isRefreshing: false, - tenantSelection: null, - tenant: null, - tenants: [], - }; - case 'LOGOUT': - return { - status: 'logged-out', - token: '', - error: null, + error: action.error ?? null, isRefreshing: false, tenantSelection: null, tenant: null, @@ -148,7 +181,7 @@ const appStateReducer = (state, action) => { } }; -const AppStateProvider = ({ children }) => { +const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => { const [state, dispatch] = useReducer(appStateReducer, initialAppState); useEffect(() => { @@ -216,7 +249,7 @@ const AppStateProvider = ({ children }) => { ); }; -const useAppState = () => { +const useAppState = (): AppState => { const context = useContext(AppStateContext); if (!context) { throw new Error('useAppState must be used within an AppStateProvider.'); @@ -224,7 +257,7 @@ const useAppState = () => { return context; }; -const useAppDispatch = () => { +const useAppDispatch = (): React.Dispatch => { const context = useContext(AppDispatchContext); if (!context) { throw new Error('useAppDispatch must be used within an AppStateProvider.'); diff --git a/frontend/src/app/useDetailPanel.js b/frontend/src/app/useDetailPanel.ts similarity index 76% rename from frontend/src/app/useDetailPanel.js rename to frontend/src/app/useDetailPanel.ts index 9df6fee..7ad3840 100644 --- a/frontend/src/app/useDetailPanel.js +++ b/frontend/src/app/useDetailPanel.ts @@ -1,13 +1,30 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +export interface DetailDocument { + id?: string | number; + [key: string]: unknown; +} + +interface UseDetailPanelOptions { + documentLookup: Map; + orderedSelectedDocuments: DetailDocument[]; +} + +interface OpenDetailPanelArgs { + documentId?: string | number; + document?: DetailDocument | null; + documentIds?: Array; + documents?: DetailDocument[]; +} + export const useDetailPanel = ({ documentLookup, orderedSelectedDocuments, -}) => { +}: UseDetailPanelOptions) => { const [detailPanelOpen, setDetailPanelOpen] = useState(false); - const [detailPanelDocId, setDetailPanelDocId] = useState(null); - const [detailPanelDocument, setDetailPanelDocument] = useState(null); - const latestOrderedDocsRef = useRef([]); + const [detailPanelDocId, setDetailPanelDocId] = useState(null); + const [detailPanelDocument, setDetailPanelDocument] = useState(null); + const latestOrderedDocsRef = useRef([]); useEffect(() => { latestOrderedDocsRef.current = orderedSelectedDocuments; @@ -34,7 +51,7 @@ export const useDetailPanel = ({ }, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]); const openDetailPanel = useCallback( - ({ documentId, document, documentIds, documents } = {}) => { + ({ documentId, document, documentIds, documents }: OpenDetailPanelArgs = {}) => { let targetDoc = document || null; let targetId = documentId ?? document?.id ?? null; @@ -66,7 +83,7 @@ export const useDetailPanel = ({ return; } - setDetailPanelDocId(targetDoc?.id || targetId || null); + setDetailPanelDocId(targetDoc?.id ?? targetId ?? null); setDetailPanelDocument(targetDoc || null); setDetailPanelOpen(Boolean(targetDoc || targetId)); }, diff --git a/frontend/src/app/useDocumentPreview.js b/frontend/src/app/useDocumentPreview.ts similarity index 62% rename from frontend/src/app/useDocumentPreview.js rename to frontend/src/app/useDocumentPreview.ts index 36f0671..4d173ac 100644 --- a/frontend/src/app/useDocumentPreview.js +++ b/frontend/src/app/useDocumentPreview.ts @@ -1,4 +1,72 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import type { + Dispatch, + MutableRefObject, + SetStateAction, +} from 'react'; + +type DocumentId = string | number; +type FolderId = DocumentId | 'root'; + +type DocumentLike = { + id?: DocumentId; + folder_id?: FolderId | null; + filename?: string | null; + current_version?: Record; + [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: (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>; +} + +interface UseDocumentPreviewResult { + previewEntries: Map; + previewDocuments: Map; + ensurePreviewUrl: (documentId: DocumentId | null, options?: { force?: boolean }) => Promise; + ensurePreviewData: (documentId: DocumentId | null) => Promise; + openDocumentPreview: (documentId: DocumentId | null, options?: { replace?: boolean }) => void; + closeDocumentPreview: (folderId?: FolderId | null) => void; + resetPreviewState: () => void; + removePreviewEntries: (ids: DocumentId[]) => void; +} const useDocumentPreview = ({ routeDocumentId, @@ -14,11 +82,11 @@ const useDocumentPreview = ({ locationSearch, detailPanelControlRef, setActivePreviewId, -}) => { - const [previewEntries, setPreviewEntries] = useState(() => new Map()); - const [previewDocuments, setPreviewDocuments] = useState(() => new Map()); - const previewInflightRef = useRef(new Map()); - const previewReturnPathRef = useRef(null); +}: UseDocumentPreviewArgs): UseDocumentPreviewResult => { + const [previewEntries, setPreviewEntries] = useState>(() => new Map()); + const [previewDocuments, setPreviewDocuments] = useState>(() => new Map()); + const previewInflightRef = useRef>>(new Map()); + const previewReturnPathRef = useRef(null); const resetPreviewState = useCallback(() => { setPreviewEntries(() => new Map()); @@ -26,7 +94,7 @@ const useDocumentPreview = ({ previewReturnPathRef.current = null; }, []); - const removePreviewEntries = useCallback((ids) => { + const removePreviewEntries = useCallback((ids: DocumentId[]) => { if (!Array.isArray(ids) || ids.length === 0) { return; } @@ -46,7 +114,7 @@ const useDocumentPreview = ({ }); }, []); - const cachePreviewDocument = useCallback((doc) => { + const cachePreviewDocument = useCallback((doc: DocumentLike | null | undefined) => { if (!doc?.id) { return; } @@ -61,7 +129,7 @@ const useDocumentPreview = ({ }); }, []); - const removeCachedPreviewDocument = useCallback((documentId) => { + const removeCachedPreviewDocument = useCallback((documentId?: DocumentId | null) => { if (!documentId) { return; } @@ -76,7 +144,7 @@ const useDocumentPreview = ({ }, []); const ensurePreviewUrl = useCallback( - async (documentId, { force = false } = {}) => { + async (documentId: DocumentId | null, { force = false }: { force?: boolean } = {}): Promise => { if (!documentId) return null; const existing = previewEntries.get(documentId) || null; @@ -87,19 +155,19 @@ const useDocumentPreview = ({ } if (!force && previewInflightRef.current.has(documentId)) { - return previewInflightRef.current.get(documentId); + return previewInflightRef.current.get(documentId) || null; } - const request = (async () => { + const request: Promise = (async () => { try { - const docResponse = await api.get(`/documents/${documentId}`); + const docResponse = await api.get<{ document?: Record }>(`/documents/${documentId}`); const downloadPath = docResponse.data?.document?.current_version?.download_path; if (!downloadPath || !resolveApiPath) { throw new Error('Document missing download path'); } const href = resolveApiPath(downloadPath); - const entry = { + const entry: PreviewEntry = { url: href, contentType: docResponse.data?.document?.current_version?.version?.content_type || null, filename: docResponse.data?.document?.filename, @@ -122,11 +190,11 @@ const useDocumentPreview = ({ previewInflightRef.current.set(documentId, request); return request; }, - [previewEntries, api, resolveApiPath, notifyApiError, setPreviewEntries], + [previewEntries, api, resolveApiPath, notifyApiError], ); const ensurePreviewData = useCallback( - async (documentId) => { + async (documentId: DocumentId | null): Promise => { if (!documentId) return null; const findInCache = () => { @@ -139,18 +207,18 @@ const useDocumentPreview = ({ if (!doc) { const { data } = await api.get(`/documents/${documentId}`); const hydratedDetail = assetManager.hydrateDetail(data); - const fetched = hydratedDetail?.document || data.document || data; - doc = fetched ? assetManager.hydrateDocument(fetched) : null; + const fetched = hydratedDetail?.document || (data as { document?: DocumentLike })?.document || data; + doc = fetched ? assetManager.hydrateDocument(fetched) || null : null; if (!doc) { throw new Error('Document metadata unavailable.'); } - const existsInDocuments = documents.some((item) => item.id === doc.id); + const existsInDocuments = documents.some((item) => item.id === doc?.id); const existsInSearch = Array.isArray(searchResults) - ? searchResults.some((item) => item.id === doc.id) + ? searchResults.some((item) => item.id === doc?.id) : false; - if (existsInDocuments || existsInSearch) { + if (doc?.id && (existsInDocuments || existsInSearch)) { removeCachedPreviewDocument(doc.id); } else { cachePreviewDocument(doc); @@ -180,9 +248,9 @@ const useDocumentPreview = ({ ); const openDocumentPreview = useCallback( - (documentId, { replace = false } = {}) => { + (documentId: DocumentId | null, { replace = false }: { replace?: boolean } = {}) => { if (!documentId) return; - detailPanelControlRef.current.close(); + detailPanelControlRef.current?.close?.(); previewReturnPathRef.current = `${locationPathname}${locationSearch}`; navigate(`/documents/${documentId}`, { replace }); }, @@ -190,7 +258,7 @@ const useDocumentPreview = ({ ); const closeDocumentPreview = useCallback( - (folderId = null) => { + (folderId: FolderId | null = null) => { const fallbackPath = previewReturnPathRef.current; previewReturnPathRef.current = null; @@ -211,7 +279,7 @@ const useDocumentPreview = ({ return undefined; } - const handleKeyDown = (event) => { + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { closeDocumentPreview(); } @@ -248,7 +316,7 @@ const useDocumentPreview = ({ } const next = new Map(prev); let changed = false; - const prune = (list) => { + const prune = (list?: DocumentLike[] | null) => { if (!Array.isArray(list)) { return; } diff --git a/frontend/src/app/useDocumentSelection.js b/frontend/src/app/useDocumentSelection.ts similarity index 74% rename from frontend/src/app/useDocumentSelection.js rename to frontend/src/app/useDocumentSelection.ts index 16f3b83..d5a3809 100644 --- a/frontend/src/app/useDocumentSelection.js +++ b/frontend/src/app/useDocumentSelection.ts @@ -1,6 +1,30 @@ import { useCallback, useRef, useState } from 'react'; -const DEFAULT_INITIAL_ENTRIES = []; +type RowKey = string; +type DocumentId = string | number; + +interface SelectionEventLike { + shiftKey?: boolean; + metaKey?: boolean; + ctrlKey?: boolean; + preventDefault?: () => void; +} + +interface UseDocumentSelectionOptions { + resolveDocumentRowKey: (id: DocumentId | null | undefined) => RowKey | null | undefined; + resolveFolderRowKey: (id: DocumentId | null | undefined) => RowKey | null | undefined; + isDocumentRowKey: (key?: RowKey | null) => boolean; + isFolderRowKey: (key?: RowKey | null) => boolean; + getRowId: (key?: RowKey | null) => DocumentId | null | undefined; + initialEntries?: RowKey[]; +} + +interface ApplySelectionOptions { + anchor?: RowKey | null; + interactedKeys?: RowKey[]; +} + +const DEFAULT_INITIAL_ENTRIES: RowKey[] = []; export const useDocumentSelection = ({ resolveDocumentRowKey, @@ -9,21 +33,24 @@ export const useDocumentSelection = ({ isFolderRowKey, getRowId, initialEntries = DEFAULT_INITIAL_ENTRIES, -}) => { - const [selectedEntries, setSelectedEntries] = useState(initialEntries); - const [selectionOrder, setSelectionOrder] = useState(initialEntries); - const selectionOrderRef = useRef(initialEntries); - const selectionAnchorRef = useRef(null); +}: UseDocumentSelectionOptions) => { + const [selectedEntries, setSelectedEntries] = useState(initialEntries); + const [selectionOrder, setSelectionOrder] = useState(initialEntries); + const selectionOrderRef = useRef(initialEntries); + const selectionAnchorRef = useRef(null); const selectionInitializedRef = useRef(false); - const [focusedDocumentId, setFocusedDocumentId] = useState(null); - const [focusedRowKey, setFocusedRowKey] = useState(null); + const [focusedDocumentId, setFocusedDocumentId] = useState(null); + const [focusedRowKey, setFocusedRowKey] = useState(null); - const visibleRowKeySetRef = useRef(new Set()); - const navigableRowKeysRef = useRef([]); + const visibleRowKeySetRef = useRef>(new Set()); + const navigableRowKeysRef = useRef([]); const configureSelectionEnvironment = useCallback(({ visibleRowKeySet, navigableRowKeys, + }: { + visibleRowKeySet?: Set; + navigableRowKeys?: RowKey[]; }) => { if (visibleRowKeySet) { visibleRowKeySetRef.current = visibleRowKeySet; @@ -33,7 +60,7 @@ export const useDocumentSelection = ({ } }, []); - const updateSelectionOrder = useCallback((nextSelection, interactedKeys = []) => { + const updateSelectionOrder = useCallback((nextSelection: RowKey[], interactedKeys: RowKey[] = []) => { const nextSet = new Set(nextSelection); const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id)); const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index); @@ -65,13 +92,16 @@ export const useDocumentSelection = ({ }, []); const applySelection = useCallback( - (rowKeys, { anchor, interactedKeys = [] } = {}) => { + ( + rowKeys: Array, + { anchor, interactedKeys = [] }: ApplySelectionOptions = {}, + ) => { const visibleRowKeySet = visibleRowKeySetRef.current; - const unique = []; + const unique: RowKey[] = []; - (rowKeys || []).forEach((key) => { + (rowKeys || []).forEach((key) => { if (!key) return; - let canonicalKey = null; + let canonicalKey: RowKey | null | undefined = null; if (visibleRowKeySet.has(key)) { canonicalKey = key; } else if (isDocumentRowKey(key)) { @@ -99,7 +129,7 @@ export const useDocumentSelection = ({ setSelectedEntries(unique); updateSelectionOrder(unique, interactedKeys); - const nextFocusedDocumentId = (() => { + const nextFocusedDocumentId: DocumentId | null = (() => { if (focusedDocumentId) { const focusKey = resolveDocumentRowKey(focusedDocumentId); if (focusKey && unique.includes(focusKey)) { @@ -108,11 +138,11 @@ export const useDocumentSelection = ({ } if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) { - return getRowId(resolvedAnchor) || null; + return getRowId(resolvedAnchor) ?? null; } - const lastDocKey = [...unique].reverse().find(isDocumentRowKey); - return lastDocKey ? getRowId(lastDocKey) || null : null; + const lastDocKey = [...unique].reverse().find((key) => isDocumentRowKey(key)) ?? null; + return lastDocKey ? getRowId(lastDocKey) ?? null : null; })(); setFocusedDocumentId(nextFocusedDocumentId); @@ -144,7 +174,7 @@ export const useDocumentSelection = ({ }, [applySelection]); const handleEntrySelection = useCallback( - (rowKey, event) => { + (rowKey: RowKey | null | undefined, event?: SelectionEventLike) => { const visibleRowKeySet = visibleRowKeySetRef.current; const navigableRowKeys = navigableRowKeysRef.current; if (!rowKey || !visibleRowKeySet.has(rowKey)) { @@ -170,8 +200,8 @@ export const useDocumentSelection = ({ anchorKey = rowKey; } - let nextKeys = []; - let interactedKeys = []; + let nextKeys: RowKey[] = []; + let interactedKeys: RowKey[] = []; if (shiftKey && anchorKey) { const anchorIndex = navigableRowKeys.indexOf(anchorKey); @@ -213,7 +243,7 @@ export const useDocumentSelection = ({ ); const promoteSelectionOrder = useCallback( - (docId) => { + (docId?: DocumentId | null) => { if (!docId) return; const rowKey = resolveDocumentRowKey(docId); if (!rowKey) return; diff --git a/frontend/src/app/useDocumentsPreferences.js b/frontend/src/app/useDocumentsPreferences.ts similarity index 89% rename from frontend/src/app/useDocumentsPreferences.js rename to frontend/src/app/useDocumentsPreferences.ts index 5317616..76fcf13 100644 --- a/frontend/src/app/useDocumentsPreferences.js +++ b/frontend/src/app/useDocumentsPreferences.ts @@ -10,7 +10,7 @@ const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field'; const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction'; const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants'; -const readSessionStorage = (key) => { +const readSessionStorage = (key: string): string | null => { try { return window.sessionStorage.getItem(key); } catch (error) { @@ -19,7 +19,7 @@ const readSessionStorage = (key) => { } }; -const writeSessionStorage = (key, value) => { +const writeSessionStorage = (key: string, value: string): void => { try { window.sessionStorage.setItem(key, value); } catch (error) { @@ -28,7 +28,7 @@ const writeSessionStorage = (key, value) => { }; export const useDocumentsPreferences = () => { - const [documentsViewMode, setDocumentsViewModeState] = useState(() => { + const [documentsViewMode, setDocumentsViewModeState] = useState<'list' | 'grid' | 'desk'>(() => { const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY); if (stored === 'grid' || stored === 'desk') { return stored; @@ -36,9 +36,9 @@ export const useDocumentsPreferences = () => { return 'list'; }); - const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode); + const lastNonDeskViewRef = useRef<'list' | 'grid'>((documentsViewMode === 'desk' ? 'list' : documentsViewMode) as 'list' | 'grid'); - const setDocumentsViewMode = useCallback((mode) => { + const setDocumentsViewMode = useCallback((mode: string) => { const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list'; setDocumentsViewModeState((previous) => { if (next !== previous) { @@ -75,7 +75,7 @@ export const useDocumentsPreferences = () => { writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection); }, [documentsSortDirection]); - const handleDocumentsSortFieldChange = useCallback((field) => { + const handleDocumentsSortFieldChange = useCallback((field: string) => { const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD; setDocumentsSortField((previous) => (previous === nextField ? previous : nextField)); }, []); diff --git a/frontend/src/app/useDocumentsSearch.js b/frontend/src/app/useDocumentsSearch.ts similarity index 65% rename from frontend/src/app/useDocumentsSearch.js rename to frontend/src/app/useDocumentsSearch.ts index e5e0447..b8e688c 100644 --- a/frontend/src/app/useDocumentsSearch.js +++ b/frontend/src/app/useDocumentsSearch.ts @@ -1,10 +1,77 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; import { TAG_FILTER_UNTAGGED, resolveDocumentRowKey, isDocumentRowKey, } from './appLayoutUtils'; +type Identifier = string | number; + +type DocumentLike = { id?: Identifier } & Record; + +type ApiClient = { + get: (url: string, config?: { params?: Record }) => Promise<{ data: T }>; +}; + +type AssetManagerLike = { + hydrateDocuments: (payload: unknown[]) => DocumentLike[]; +}; + +type SelectionHelpers = { + setSelectedEntries: ( + updater: + | Array + | ((prev: Array) => Array), + ) => void; + setSelectionOrder: ( + updater: + | Array + | ((prev: Array) => Array), + ) => void; + selectionOrderRef: { current: Array }; + 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>; + searchResults: DocumentLike[] | null; + setSearchResults: Dispatch>; + searchLoading: boolean; + setSearchLoading: Dispatch>; + activeTagFilters: Identifier[]; + setActiveTagFilters: Dispatch>; + activeCorrespondentFilters: Identifier[]; + setActiveCorrespondentFilters: Dispatch>; + toggleTagFilter: (tagId: Identifier) => void; + toggleCorrespondentFilter: (correspondentId?: Identifier | null) => void; + isFilterActive: boolean; + clearFilters: () => void; + handleSearchChange: (value: string) => void; + handleSearchSubmit: () => void; +} + const useDocumentsSearch = ({ api, assetManager, @@ -20,14 +87,14 @@ const useDocumentsSearch = ({ notifyApiError, setLoading, setSearchIncludeDescendants, -}) => { - const [searchQuery, setSearchQuery] = useState(''); - const [activeTagFilters, setActiveTagFilters] = useState([]); - const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); - const [searchResults, setSearchResults] = useState(null); - const [searchLoading, setSearchLoading] = useState(false); +}: UseDocumentsSearchArgs): UseDocumentsSearchResult => { + const [searchQuery, setSearchQuery] = useState(''); + const [activeTagFilters, setActiveTagFilters] = useState([]); + const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); + const [searchResults, setSearchResults] = useState(null); + const [searchLoading, setSearchLoading] = useState(false); - const toggleTagFilter = useCallback((tagId) => { + const toggleTagFilter = useCallback((tagId: Identifier) => { if (!tagId) return; setActiveTagFilters((previous) => { if (tagId === TAG_FILTER_UNTAGGED) { @@ -41,7 +108,7 @@ const useDocumentsSearch = ({ }); }, []); - const toggleCorrespondentFilter = useCallback((correspondentId) => { + const toggleCorrespondentFilter = useCallback((correspondentId?: Identifier | null) => { setActiveCorrespondentFilters((previous) => { if (!correspondentId) { return []; @@ -68,7 +135,7 @@ const useDocumentsSearch = ({ setSearchIncludeDescendants, ]); - const handleSearchChange = useCallback((value) => { + const handleSearchChange = useCallback((value: string) => { setSearchQuery(value); }, []); @@ -98,7 +165,7 @@ const useDocumentsSearch = ({ started = true; setLoading(true); try { - const params = {}; + const params: Record = {}; const trimmedQuery = searchQuery.trim(); if (trimmedQuery.length) { params.query = trimmedQuery; @@ -131,7 +198,7 @@ const useDocumentsSearch = ({ if (documentsSortDirection) { params.dir = documentsSortDirection; } - const { data } = await api.get('/documents', { params }); + const { data } = await api.get('/documents', { params }); if (cancelled) return; const results = assetManager.hydrateDocuments(data || []); @@ -148,11 +215,11 @@ const useDocumentsSearch = ({ } const resultKeys = results - .map((doc) => resolveDocumentRowKey(doc.id)) - .filter(Boolean); + .map((doc) => resolveDocumentRowKey(doc.id as Identifier)) + .filter(Boolean) as Array; - let targetKey = null; - let nextSelectionKeys = []; + let targetKey: Identifier | string | null = null; + let nextSelectionKeys: Array = []; selectionHelpers.setSelectedEntries((previous) => { const previousDocKeys = previous.filter(isDocumentRowKey); @@ -171,7 +238,7 @@ const useDocumentsSearch = ({ selectionHelpers.setSelectionOrder(nextSelectionKeys); selectionHelpers.setFocusedDocumentId((previous) => { - if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) { + if (previous && resultKeys.includes(resolveDocumentRowKey(previous as Identifier))) { return previous; } return null; diff --git a/frontend/src/app/useManagementModals.js b/frontend/src/app/useManagementModals.tsx similarity index 75% rename from frontend/src/app/useManagementModals.js rename to frontend/src/app/useManagementModals.tsx index 76a26ad..319c635 100644 --- a/frontend/src/app/useManagementModals.js +++ b/frontend/src/app/useManagementModals.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; import TagsPanel from '../tags/TagsPanel'; import CorrespondentsPanel from '../correspondents/CorrespondentsPanel'; import PanelHeader from '../ui/PanelHeader'; @@ -6,21 +7,56 @@ import PanelHeader from '../ui/PanelHeader'; const TAGS_MODAL = 'tags'; const CORRESPONDENTS_MODAL = 'correspondents'; +interface TagRecord { + id?: string | number; + label?: string; + [key: string]: unknown; +} + +interface CorrespondentRecord { + id?: string | number; + name?: string; + [key: string]: unknown; +} + +interface UseManagementModalsArgs { + locationPathname?: string; + tags?: TagRecord[]; + refreshTags?: () => void | Promise; + onTagCreate?: (...args: any[]) => void | Promise; + onTagUpdate?: (...args: any[]) => void | Promise; + onTagDelete?: (...args: any[]) => void | Promise; + correspondents?: CorrespondentRecord[]; + refreshCorrespondents?: () => void | Promise; + onCorrespondentCreate?: (...args: any[]) => void | Promise; + onCorrespondentUpdate?: (...args: any[]) => void | Promise; + onCorrespondentDelete?: (...args: any[]) => void | Promise; + setStatusMessage?: (message: string, variant?: string) => void; +} + +interface UseManagementModalsResult { + managementModals: ReactNode; + openTagsModal: () => void; + openCorrespondentsModal: () => void; + closeActiveModal: () => void; + activeModal: string | null; +} + export const useManagementModals = ({ locationPathname, - tags, + tags = [], refreshTags, onTagCreate, onTagUpdate, onTagDelete, - correspondents, + correspondents = [], refreshCorrespondents, onCorrespondentCreate, onCorrespondentUpdate, onCorrespondentDelete, setStatusMessage, -}) => { - const [activeModal, setActiveModal] = useState(null); +}: UseManagementModalsArgs): UseManagementModalsResult => { + const [activeModal, setActiveModal] = useState(null); const openTagsModal = useCallback(() => setActiveModal(TAGS_MODAL), []); const openCorrespondentsModal = useCallback( @@ -37,7 +73,7 @@ export const useManagementModals = ({ if (!activeModal) { return undefined; } - const handleKeyDown = (event) => { + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); setActiveModal(null); diff --git a/frontend/src/app/useWorkspaceSelection.js b/frontend/src/app/useWorkspaceSelection.ts similarity index 69% rename from frontend/src/app/useWorkspaceSelection.js rename to frontend/src/app/useWorkspaceSelection.ts index 72d00ff..1823b71 100644 --- a/frontend/src/app/useWorkspaceSelection.js +++ b/frontend/src/app/useWorkspaceSelection.ts @@ -1,17 +1,34 @@ import { useCallback, useMemo } from 'react'; import { useDocumentSelection } from './useDocumentSelection'; -const identity = (value) => value; +type RowKey = string; + +interface SelectionEntry { + rowKey?: RowKey; + [key: string]: unknown; +} + +interface WorkspaceSelectionOptions { + resolveDocumentRowKey?: (id: string | number) => RowKey | null | undefined; + resolveFolderRowKey?: (id: string | number) => RowKey | null | undefined; + isDocumentRowKey?: (key: RowKey | SelectionEntry) => boolean; + isFolderRowKey?: (key: RowKey | SelectionEntry) => boolean; + getRowId?: (key: RowKey | SelectionEntry) => string | number | null; + onInspectDocument?: (id: string | number) => void; + onInspectFolder?: (id: string | number) => void; +} + +const identity = (value: T) => value; export const useWorkspaceSelection = ({ resolveDocumentRowKey, resolveFolderRowKey, - isDocumentRowKey, - isFolderRowKey, - getRowId, + isDocumentRowKey = () => false, + isFolderRowKey = () => false, + getRowId = () => null, onInspectDocument = identity, onInspectFolder = identity, -} = {}) => { +}: WorkspaceSelectionOptions = {}) => { const selection = useDocumentSelection({ resolveDocumentRowKey, resolveFolderRowKey, @@ -58,8 +75,10 @@ export const useWorkspaceSelection = ({ ); const selectEntry = useCallback( - (entry, event) => { - const rowKey = entry?.rowKey ?? (typeof entry?.split === 'function' ? entry : null); + (entry: SelectionEntry | string | null, event?: unknown) => { + const rowKey = typeof entry === 'string' + ? entry + : entry?.rowKey ?? null; if (!rowKey) return; handleEntrySelection(rowKey, event); }, @@ -67,7 +86,7 @@ export const useWorkspaceSelection = ({ ); const inspectDocument = useCallback( - (documentId) => { + (documentId?: string | number | null) => { if (!documentId) return; onInspectDocument(documentId); }, @@ -75,7 +94,7 @@ export const useWorkspaceSelection = ({ ); const inspectFolder = useCallback( - (folderId) => { + (folderId?: string | number | null) => { if (!folderId) return; onInspectFolder(folderId); }, diff --git a/frontend/src/app/useWorkspaceSurface.js b/frontend/src/app/useWorkspaceSurface.tsx similarity index 64% rename from frontend/src/app/useWorkspaceSurface.js rename to frontend/src/app/useWorkspaceSurface.tsx index 1d59a80..aba8ed6 100644 --- a/frontend/src/app/useWorkspaceSurface.js +++ b/frontend/src/app/useWorkspaceSurface.tsx @@ -1,17 +1,63 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; +import type { ReactNode } from 'react'; import { SidebarExpandIcon } from '../ui/icons'; import { createDocumentsSurface } from '../documents/DocumentsPanel'; import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel'; import createDesktopSurface from '../desktop/createDesktopSurface'; import { usePanelManager } from './PanelManagerContext'; +type Identifier = string | number; + +type EnsureAssetUrl = (docId: Identifier, asset: unknown, options?: Record) => Promise | void; +type EnsurePreviewData = (docId: Identifier, options?: Record) => Promise; +type GetDocumentAsset = (document: unknown, assetType: string) => unknown; +type ResolveApiPath = (path: string) => string; +type NotifyApiError = (error: unknown, fallbackMessage?: string) => void; + +type DocumentsSurface = ReturnType | null; +type PreviewSurface = ReturnType | null; +type DesktopSurface = ReturnType | 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 | null; + detailPanelProps?: (Record & { onClose?: () => void }) | null; + detailPanelOpen?: boolean; + viewMode?: string; + deskWorkspaceProps?: Record | null; + previewWorkspaceDocument?: unknown; + previewWorkspaceEntry?: unknown; + previewDocumentId?: Identifier | null; + ensureAssetUrl?: EnsureAssetUrl; + ensurePreviewData?: EnsurePreviewData; + getDocumentAsset?: GetDocumentAsset; + resolveApiPath?: ResolveApiPath; + notifyApiError?: NotifyApiError; + closeDocumentPreview?: () => void; + parentBreadcrumb?: Breadcrumb | null; + onNavigateParent?: () => void; +} + +interface UseWorkspaceSurfaceResult { + surface: WorkspaceSurface; +} + export const useWorkspaceSurface = ({ - sidebarHidden, + sidebarHidden = false, onExpandSidebar, documentsTableProps, detailPanelProps, - detailPanelOpen, - viewMode, + detailPanelOpen = false, + viewMode = 'list', deskWorkspaceProps, previewWorkspaceDocument, previewWorkspaceEntry, @@ -24,7 +70,7 @@ export const useWorkspaceSurface = ({ closeDocumentPreview, parentBreadcrumb, onNavigateParent, -}) => { +}: UseWorkspaceSurfaceArgs): UseWorkspaceSurfaceResult => { const { registerDetailCloseHandler, setDetailActive } = usePanelManager(); useEffect(() => { @@ -38,7 +84,7 @@ export const useWorkspaceSurface = ({ return () => setDetailActive(false); }, [detailPanelOpen, setDetailActive]); - const renderSidebarToggle = useCallback(() => { + const renderSidebarToggle = useCallback<() => ReactNode>(() => { if (!sidebarHidden) { return null; } @@ -55,7 +101,7 @@ export const useWorkspaceSurface = ({ ); }, [sidebarHidden, onExpandSidebar]); - const documentsSurface = useMemo(() => { + const documentsSurface = useMemo(() => { if (!documentsTableProps) { return null; } @@ -78,7 +124,7 @@ export const useWorkspaceSurface = ({ const showPreviewWorkspace = Boolean(previewDocumentId); - const previewSurface = useMemo(() => { + const previewSurface = useMemo(() => { if (!showPreviewWorkspace) { return null; } @@ -132,7 +178,7 @@ export const useWorkspaceSurface = ({ detailPanelProps, ]); - const workspaceSurface = useMemo(() => { + const workspaceSurface = useMemo(() => { if (viewMode !== 'desk') { return null; } @@ -154,7 +200,7 @@ export const useWorkspaceSurface = ({ detailPanelOpen, ]); - const surface = useMemo(() => { + const surface = useMemo(() => { if (showPreviewWorkspace) { return previewSurface; } diff --git a/frontend/src/appShellContext.js b/frontend/src/appShellContext.ts similarity index 51% rename from frontend/src/appShellContext.js rename to frontend/src/appShellContext.ts index 054a48c..d4b30cc 100644 --- a/frontend/src/appShellContext.js +++ b/frontend/src/appShellContext.ts @@ -1,12 +1,13 @@ import React from 'react'; -export const AppShellContext = React.createContext(null); +export type AppShellContextValue = Record; -export const useAppShell = () => { +export const AppShellContext = React.createContext(null); + +export const useAppShell = (): AppShellContextValue => { const context = React.useContext(AppShellContext); if (!context) { throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.'); } return context; }; - diff --git a/frontend/src/asset_manager.js b/frontend/src/asset_manager.ts similarity index 70% rename from frontend/src/asset_manager.js rename to frontend/src/asset_manager.ts index 44054a4..ea87889 100644 --- a/frontend/src/asset_manager.js +++ b/frontend/src/asset_manager.ts @@ -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 | null; + expires_at?: number | null; + [key: string]: unknown; +} + +export interface AssetLike { + id?: Identifier; + asset_type?: string; + cardinality?: number | null; + url?: string | null; + metadata?: Record | null; + expiresAt?: number | null; + assets?: Record | AssetLike[] | null; + objects?: AssetObject[] | null; + [key: string]: unknown; +} + +export interface DocumentVersionLike { + assets?: Record | AssetLike[] | null; + metadata?: Record & { 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; + +export type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null | undefined; + +export const getAssetFromGroup = ( + assets: AssetLike[] | Record | null | undefined, + assetType: string, +): AssetLike | null => { if (!assetType || !assets) { return null; } @@ -17,7 +64,7 @@ export const getAssetFromVersion = (currentVersion, assetType) => { return getAssetFromGroup(currentVersion.assets, assetType); }; -const normalizeAssetObjects = (objects) => { +const normalizeAssetObjects = (objects?: AssetObject[] | null): AssetObject[] => { if (!Array.isArray(objects)) { return []; } @@ -27,31 +74,42 @@ const normalizeAssetObjects = (objects) => { .sort((a, b) => a.ordinal - b.ordinal); }; -const mergeAssetObjects = (existingObjects, incomingObjects) => { - const merged = new Map(); +const mergeAssetObjects = ( + existingObjects?: AssetObject[] | null, + incomingObjects?: AssetObject[] | null, +): AssetObject[] => { + const merged = new Map(); normalizeAssetObjects(existingObjects).forEach((entry) => { - merged.set(entry.ordinal, { ...entry }); + if (typeof entry.ordinal === 'number') { + merged.set(entry.ordinal, { ...entry }); + } }); normalizeAssetObjects(incomingObjects).forEach((entry) => { - const current = merged.get(entry.ordinal) || {}; - merged.set(entry.ordinal, { ...current, ...entry }); + if (typeof entry.ordinal === 'number') { + const current = merged.get(entry.ordinal) || {}; + merged.set(entry.ordinal, { ...current, ...entry }); + } }); - return [...merged.entries()] + return Array.from(merged.entries()) .sort((a, b) => a[0] - b[0]) .map(([, value]) => value); }; export class AssetView { - constructor(asset) { + asset: AssetLike | null; + private _objectsRef: AssetObject[] | null; + private _sortedObjects: AssetObject[]; + + constructor(asset?: AssetLike | null) { this.asset = asset || null; this._objectsRef = null; this._sortedObjects = []; } - getCardinality() { + getCardinality(): number { if (!this.asset) { return 0; } @@ -69,7 +127,7 @@ export class AssetView { return this.asset.metadata ? 1 : 0; } - getObjects() { + getObjects(): AssetObject[] { if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) { return []; } @@ -83,7 +141,7 @@ export class AssetView { return this._sortedObjects; } - getObject(ordinal = 1) { + getObject(ordinal = 1): AssetObject | null { const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal); if (fromObjects) { return fromObjects; @@ -103,30 +161,40 @@ export class AssetView { return null; } - getPrimaryObject() { + getPrimaryObject(): AssetObject | null { return this.getObject(1); } - getPrimaryMetadata() { + getPrimaryMetadata(): Record | null { return this.getPrimaryObject()?.metadata || null; } - getPrimaryUrl() { + getPrimaryUrl(): string | null { return this.getPrimaryObject()?.url || null; } - hasObject(ordinal) { + hasObject(ordinal: number): boolean { return Boolean(this.getObject(ordinal)); } } -export const createAssetView = (asset) => new AssetView(asset); +export const createAssetView = (asset?: AssetLike | null): AssetView => new AssetView(asset); export const resolveDocumentAssetUrl = ( - doc, - type, - { ensureAssetUrl, getAsset, ensureOptions, objectOrdinal = 1 } = {}, -) => { + doc: DocumentLike | null | undefined, + type: string, + { + ensureAssetUrl, + getAsset, + ensureOptions, + objectOrdinal = 1, + }: { + ensureAssetUrl?: EnsureAssetUrl; + getAsset?: GetAsset; + ensureOptions?: { start?: number; limit?: number; [key: string]: unknown }; + objectOrdinal?: number; + } = {}, +): string | null => { if (!doc || !type) { return null; } @@ -148,7 +216,7 @@ export const resolveDocumentAssetUrl = ( } if (doc.id && asset.id && typeof ensureAssetUrl === 'function') { const force = Boolean(url && expiresAt && expiresAt <= now); - const options = { + const options: { force: boolean; start?: number | null; limit?: number | null; [key: string]: unknown } = { force, start: objectOrdinal, limit: 1, @@ -166,24 +234,29 @@ export const resolveDocumentAssetUrl = ( }; class AssetManager { - constructor({ api, assetPresignTtlMs }) { + api: AxiosInstance | null; + assetPresignTtlMs: number; + assetCache: Map; + assetInflight: Map>; + + constructor({ api, assetPresignTtlMs }: { api: AxiosInstance | null; assetPresignTtlMs: number }) { this.api = api; this.assetPresignTtlMs = assetPresignTtlMs; this.assetCache = new Map(); this.assetInflight = new Map(); } - setApi(api) { + setApi(api: AxiosInstance | null) { this.api = api; } - rememberAsset(entry) { + rememberAsset(entry: AssetLike | null | undefined) { if (entry?.id) { this.assetCache.set(entry.id, entry); } } - hydrateAsset(asset) { + hydrateAsset(asset: AssetLike | null | undefined): AssetLike | null | undefined { if (!asset || !asset.id) { return asset; } @@ -213,7 +286,7 @@ class AssetManager { return merged; } - hydrateDocument(document) { + hydrateDocument(document: DocumentLike | null | undefined): DocumentLike | null | undefined { if (!document) { return document; } @@ -252,18 +325,18 @@ class AssetManager { return document; } - const nextCurrentVersion = { ...currentVersion, assets: nextAssets }; + const nextCurrentVersion: DocumentVersionLike = { ...currentVersion, assets: nextAssets }; return { ...document, current_version: nextCurrentVersion }; } - hydrateDocuments(documents) { + hydrateDocuments(documents: DocumentLike[] | null | undefined) { if (!Array.isArray(documents)) { return documents; } return documents.map((doc) => this.hydrateDocument(doc)); } - hydrateDetail(detail) { + hydrateDetail(detail: { document?: DocumentLike; assets?: AssetLike[] } | null | undefined) { if (!detail) { return detail; } @@ -292,7 +365,10 @@ class AssetManager { return changed ? next : detail; } - hydrateFolderContents(contents) { + hydrateFolderContents(contents: { + documents?: DocumentLike[]; + document?: DocumentLike; + } | null | undefined) { if (!contents) { return contents; } @@ -306,7 +382,11 @@ class AssetManager { return next; } - ensureAsset(documentId, asset, { force = false, start = null, limit = null } = {}) { + ensureAsset( + documentId: Identifier | null | undefined, + asset: AssetLike | null | undefined, + { force = false, start = null, limit = null }: { force?: boolean; start?: number | null; limit?: number | null } = {}, + ): Promise { if (!documentId || !asset?.id) { return Promise.resolve(asset || null); } @@ -361,7 +441,7 @@ class AssetManager { return Promise.reject(new Error('AssetManager API client is not configured.')); } - const params = {}; + const params: Record = {}; if (Number.isInteger(start) && start > 0) { params.start = start; } @@ -371,7 +451,7 @@ class AssetManager { const requestConfig = Object.keys(params).length ? { params } : undefined; - const request = this.api + const request: Promise = this.api .get(`/assets/${asset.id}`, requestConfig) .then(({ data }) => { const incomingObjects = Array.isArray(data.objects) ? data.objects : []; diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg new file mode 100644 index 0000000..41b9561 --- /dev/null +++ b/frontend/src/assets/logo.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/correspondents/CorrespondentsPanel.jsx b/frontend/src/correspondents/CorrespondentsPanel.tsx similarity index 85% rename from frontend/src/correspondents/CorrespondentsPanel.jsx rename to frontend/src/correspondents/CorrespondentsPanel.tsx index 845090a..ee599b4 100644 --- a/frontend/src/correspondents/CorrespondentsPanel.jsx +++ b/frontend/src/correspondents/CorrespondentsPanel.tsx @@ -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; + onCreate: (payload: { name: string }) => Promise; + onUpdate: (id: string | number, payload: { name: string }) => Promise; + onDelete: (id: string | number) => Promise; + onNotify?: (message: string, variant?: string) => void; +} function CorrespondentsPanel({ correspondents = [], @@ -7,15 +29,15 @@ function CorrespondentsPanel({ onUpdate, onDelete, onNotify, -}) { - const [editingId, setEditingId] = useState(null); +}: CorrespondentsPanelProps) { + const [editingId, setEditingId] = useState(null); const [draftName, setDraftName] = useState(''); const [createName, setCreateName] = useState(''); const [saving, setSaving] = useState(false); const [creating, setCreating] = useState(false); - const [deletingId, setDeletingId] = useState(null); + const [deletingId, setDeletingId] = useState(null); - const startEdit = useCallback((correspondent) => { + const startEdit = useCallback((correspondent: CorrespondentEntry) => { setEditingId(correspondent.id); setDraftName(correspondent.name); }, []); @@ -46,7 +68,7 @@ function CorrespondentsPanel({ }, [editingId, draftName, onUpdate, cancelEdit, onNotify]); const handleDelete = useCallback( - async (correspondent) => { + async (correspondent: CorrespondentEntry) => { if (!correspondent?.id) return; setDeletingId(correspondent.id); try { @@ -65,7 +87,7 @@ function CorrespondentsPanel({ ); const handleCreate = useCallback( - async (event) => { + async (event: FormEvent) => { event.preventDefault(); const trimmed = createName.trim(); if (!trimmed) { @@ -87,7 +109,7 @@ function CorrespondentsPanel({ ); const handleKeyDown = useCallback( - (event) => { + (event: KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault(); handleSave(); @@ -99,7 +121,7 @@ function CorrespondentsPanel({ [handleSave, cancelEdit], ); - const renderUsage = useCallback((usage) => { + const renderUsage = useCallback((usage?: CorrespondentUsage) => { if (!usage) { return '0'; } diff --git a/frontend/src/desktop/DesktopDocumentCard.jsx b/frontend/src/desktop/DesktopDocumentCard.tsx similarity index 61% rename from frontend/src/desktop/DesktopDocumentCard.jsx rename to frontend/src/desktop/DesktopDocumentCard.tsx index 7bb23f8..ab4485a 100644 --- a/frontend/src/desktop/DesktopDocumentCard.jsx +++ b/frontend/src/desktop/DesktopDocumentCard.tsx @@ -4,7 +4,46 @@ import { resolveCorrespondents } from '../documents/correspondents'; import { getTagColorStyle } from '../utils/colors'; import { preventAll } from './events'; -const DesktopDocumentCard = ({ +type DocumentLike = { + id?: string | number; + title?: string; + tags?: Array<{ id?: string | number; label?: string; color?: string | null }>; + [key: string]: unknown; +}; + +interface PendingRemovalTag { + docId?: string | number; + tagId?: string | number; +} + +interface DesktopDocumentCardProps { + doc: DocumentLike; + style?: React.CSSProperties; + shouldLoad?: boolean; + dragging?: boolean; + matchesFilter?: boolean; + tagTargetActive?: boolean; + tagTargetPending?: boolean; + selected?: boolean; + docTagTokens?: string; + ensureAssetUrl?: (...args: any[]) => Promise; + getDocumentAsset?: (...args: any[]) => unknown; + handleNavigatorSnapshot?: (...args: any[]) => void; + cardPointerHandlers?: React.HTMLAttributes; + onDocumentOpen?: (id: string | number) => void; + onTagDragEnter?: (event: React.DragEvent, docId: string | number) => void; + onTagDragOver?: (event: React.DragEvent, docId: string | number) => void; + onTagDragLeave?: (event: React.DragEvent, docId: string | number) => void; + onTagDrop?: (event: React.DragEvent, doc: DocumentLike) => void; + onDocTagPointerDown?: (event: React.PointerEvent, doc: DocumentLike, tag: any) => void; + onDocTagDragStart?: (event: React.DragEvent, doc: DocumentLike, tag: any) => void; + onDocTagDrag?: (event: React.DragEvent) => void; + onDocTagDragEnd?: (event: React.DragEvent) => void; + pendingRemovalTag?: PendingRemovalTag | null; + registerNode?: (node: HTMLDivElement | null) => void; +} + +const DesktopDocumentCard: React.FC = ({ doc, style, shouldLoad, @@ -52,12 +91,29 @@ const DesktopDocumentCard = ({ data-doc-id={doc.id} data-tag-ids={dataTagIds} aria-hidden={ariaHidden} - ref={registerNode} + ref={registerNode ?? undefined} {...cardPointerHandlers} - onDragEnter={(event) => onTagDragEnter(event, doc.id)} - onDragOver={(event) => onTagDragOver(event, doc.id)} - onDragLeave={(event) => onTagDragLeave(event, doc.id)} - onDrop={(event) => onTagDrop(event, doc)} + onDragEnter={(event) => { + if (doc?.id == null) { + return; + } + onTagDragEnter?.(event, doc.id); + }} + onDragOver={(event) => { + if (doc?.id == null) { + return; + } + onTagDragOver?.(event, doc.id); + }} + onDragLeave={(event) => { + if (doc?.id == null) { + return; + } + onTagDragLeave?.(event, doc.id); + }} + onDrop={(event) => { + onTagDrop?.(event, doc); + }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { preventAll(event); @@ -107,11 +163,11 @@ const DesktopDocumentCard = ({ draggable data-desk-tag-chip="true" onPointerDownCapture={(event) => { - onDocTagPointerDown(event, doc, tag); + onDocTagPointerDown?.(event, doc, tag); }} - onDragStart={(event) => onDocTagDragStart(event, doc, tag)} + onDragStart={(event) => onDocTagDragStart?.(event, doc, tag)} onDrag={onDocTagDrag} - onDragEnd={(event) => onDocTagDragEnd(event)} + onDragEnd={(event) => onDocTagDragEnd?.(event)} > {tag.label} diff --git a/frontend/src/desktop/DesktopPreviewCard.jsx b/frontend/src/desktop/DesktopPreviewCard.tsx similarity index 72% rename from frontend/src/desktop/DesktopPreviewCard.jsx rename to frontend/src/desktop/DesktopPreviewCard.tsx index 201c637..f8a595e 100644 --- a/frontend/src/desktop/DesktopPreviewCard.jsx +++ b/frontend/src/desktop/DesktopPreviewCard.tsx @@ -1,8 +1,56 @@ -import React, { useEffect } from 'react'; +import { useEffect } from 'react'; +import type { JSX } from 'react'; import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; import { useAssetNavigator } from '../hooks/useAssetNavigator'; import { preventAll } from './events'; +type Identifier = string | number; + +interface DocumentLike { + id?: Identifier; + title?: string; + [key: string]: unknown; +} + +interface AssetLike { + id?: Identifier; + cardinality?: number; + url?: string | null; + metadata?: Record | null; + objects?: Array>; + [key: string]: unknown; +} + +type EnsureAssetUrl = ( + documentId: Identifier, + asset: AssetLike, + options?: { start?: number; limit?: number; [key: string]: unknown }, +) => Promise; + +type GetDocumentAsset = (document: DocumentLike | null | undefined, assetType: string) => AssetLike | null | undefined; + +interface NavigatorSnapshot { + url: string | null; + alt?: string; + canGoPrev: boolean; + canGoNext: boolean; + goPrev?: () => void; + goNext?: () => void; + ordinal: number; + width: number | null; + height: number | null; +} + +interface DesktopPreviewCardProps { + doc: DocumentLike | null; + title?: string; + ensureAssetUrl?: EnsureAssetUrl | null; + getDocumentAsset: GetDocumentAsset; + prefetch?: number; + onNavigatorSnapshot?: (docId: Identifier, snapshot: NavigatorSnapshot | null) => void; + shouldLoad?: boolean; +} + const DesktopPreviewCard = ({ doc, title, @@ -11,7 +59,7 @@ const DesktopPreviewCard = ({ prefetch = 3, onNavigatorSnapshot, shouldLoad = true, -}) => { +}: DesktopPreviewCardProps): JSX.Element => { const navigator = useAssetNavigator({ document: doc, assetType: 'preview', @@ -23,8 +71,8 @@ const DesktopPreviewCard = ({ const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator; const docId = doc?.id ?? null; - const metadataWidth = Number(currentMetadata?.width); - const metadataHeight = Number(currentMetadata?.height); + const metadataWidth = Number((currentMetadata as { width?: number } | null)?.width); + const metadataHeight = Number((currentMetadata as { height?: number } | null)?.height); useEffect(() => { if (!onNavigatorSnapshot || !docId) { @@ -143,4 +191,3 @@ const DesktopPreviewCard = ({ }; export default DesktopPreviewCard; - diff --git a/frontend/src/desktop/DesktopWorkspace.jsx b/frontend/src/desktop/DesktopWorkspace.tsx similarity index 71% rename from frontend/src/desktop/DesktopWorkspace.jsx rename to frontend/src/desktop/DesktopWorkspace.tsx index a884121..fe29760 100644 --- a/frontend/src/desktop/DesktopWorkspace.jsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -28,9 +28,168 @@ import '../styles/workspace/workspace-layout.css'; import '../styles/workspace/workspace-items.css'; import '../styles/workspace/workspace-cards.css'; +type Identifier = string | number; + +type TagLike = { id?: Identifier | null; label?: string; color?: string | null } | null | undefined; + +export interface DeskDocument { + id?: Identifier | null; + title?: string; + tags?: TagLike[] | null; + [key: string]: unknown; +} + +interface NavigatorSnapshot { + url: string | null; + alt?: string | null; + canGoPrev?: boolean; + canGoNext?: boolean; + goPrev?: () => void; + goNext?: () => void; + ordinal?: number | null; + width?: number | null; + height?: number | null; +} + +interface OverlayOriginTransform { + rotation: number; + scaleX: number; + scaleY: number; + baseWidth: number; + baseHeight: number; +} + +interface OverlayDisplay extends NavigatorSnapshot { + url: string; +} + +interface DocumentSizeInfo { + width: number; + height: number; + source?: 'snapshot' | 'metadata' | 'fallback'; +} + +interface PreviewMetadataEntry { + docId: string; + width: number; + height: number; +} + +interface DragTransformOverride { + centerX?: number; + centerY?: number; + rotation?: number; + scale?: number; +} + +interface DragSettings { + canvasPadding: number; + defaultCanvasWidth: number; + defaultCanvasHeight: number; + debugDrag?: boolean; +} + +interface LayoutEntry { + centerX: number; + centerY: number; + rotation: number; + z: number; + width?: number; + height?: number; +} + +type WorkspaceSnapshotState = { + layout: Map; + canvasSize: { width: number; height: number }; + visibleDocIds: Set; + 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) => 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; + getDocumentAsset?: (...args: unknown[]) => unknown; + activeTagIds?: Array; + selectedDocumentIds?: Identifier[]; + onClearSelection?: () => void; + detailPanelOpen?: boolean; + onCloseDetailPanel?: () => void; + tenantId?: Identifier | null; + viewId?: string | null; +} + +interface DesktopWorkspaceViewProps { + engine: WorkspaceEngine; + items: DeskDocument[]; + containerRef: React.RefObject; + handleCanvasDragOver: (event: React.DragEvent) => void; + handleCanvasDragLeave: (event: React.DragEvent) => void; + handleCanvasDrop: (event: React.DragEvent) => void; + ensureDocumentSize: (doc: DeskDocument | null | undefined) => DocumentSizeInfo | null; + layoutSnapshot: Map; + layoutRef: React.MutableRefObject>; + dragTransformsRef: React.MutableRefObject>; + itemRefs: React.MutableRefObject>; + visibleDocIds: Set; + 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; + 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; + resolveBaseMetrics: (doc: DeskDocument | null | undefined, cardWidth: number, cardHeight: number) => { + baseWidth: number; + baseHeight: number; + baseScale: number; + }; + bringToFront: (docId: Identifier | null | undefined) => void; + setDraggingId: (value: string | null) => void; + canvasSize: { width: number; height: number }; + openOverlayForDoc: (docId: Identifier | null | undefined, originInfo?: OverlayOriginTransform | null) => void; + recalcVisibleDocIds: () => void; + dragSettings: DragSettings; + onInspectDocument?: DesktopWorkspaceProps['onInspectDocument']; + markLayoutDirty: () => void; +} + const DEBUG_DRAG = false; const DEBUG_FOCUS = false; -const DesktopWorkspace = ({ +const DesktopWorkspace: React.FC = ({ documents = [], searchResults = null, onDocumentOpen, @@ -50,28 +209,35 @@ const DesktopWorkspace = ({ tenantId = null, viewId = 'default', }) => { - const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); + const items = useMemo( + () => (searchResults ? searchResults : documents), + [documents, searchResults], + ); const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:')); - const containerRef = useRef(null); - const itemRefs = useRef(new Map()); - const dragTransformsRef = useRef(new Map()); - const [overlayDocId, setOverlayDocId] = useState(null); - const [overlayOriginRect, setOverlayOriginRect] = useState(null); - const [overlayOriginTransform, setOverlayOriginTransform] = useState(null); - const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map()); + const containerRef = useRef(null); + const itemRefs = useRef>(new Map()); + const dragTransformsRef = useRef>(new Map()); + const [overlayDocId, setOverlayDocId] = useState(null); + const [overlayOriginRect, setOverlayOriginRect] = useState(null); + const [overlayOriginTransform, setOverlayOriginTransform] = useState( + null, + ); + const [previewSnapshots, setPreviewSnapshots] = useState>( + () => new Map(), + ); const [docSizeVersion, setDocSizeVersion] = useState(0); - const docSizeMapRef = useRef(new Map()); - const ensureDocumentSize = useCallback((doc) => { + const docSizeMapRef = useRef>(new Map()); + const ensureDocumentSize = useCallback((doc: DeskDocument | null | undefined): DocumentSizeInfo | null => { if (!doc?.id) { return null; } return docSizeMapRef.current.get(String(doc.id)) || null; }, []); const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl); - const documentLookup = useMemo(() => { - const map = new Map(); + const documentLookup = useMemo>(() => { + const map = new Map(); items.forEach((doc) => { const key = doc?.id != null ? String(doc.id) : null; if (key) { @@ -81,7 +247,7 @@ const DesktopWorkspace = ({ return map; }, [items]); - const engineRef = useRef(null); + const engineRef = useRef(null); if (!engineRef.current) { engineRef.current = new WorkspaceEngine({ allowLayoutPersistence, @@ -89,7 +255,7 @@ const DesktopWorkspace = ({ viewId, }); } - const engine = engineRef.current; + const engine = engineRef.current as WorkspaceEngine; useEffect(() => { engine.updateConfig({ allowLayoutPersistence, tenantId, viewId }); @@ -115,7 +281,7 @@ const DesktopWorkspace = ({ engine.setEnsureDocumentSize(ensureDocumentSize); }, [engine, ensureDocumentSize]); - const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore); + const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore) as WorkspaceSnapshotState; const { layout: layoutSnapshot, canvasSize, @@ -141,10 +307,10 @@ const DesktopWorkspace = ({ engine.setItemRefs(itemRefs); }, [engine, itemRefs]); - const layoutRef = useRef(layoutSnapshot); - layoutRef.current = engine.layout; + const layoutRef = useRef>(layoutSnapshot); + layoutRef.current = engine.layout as Map; - const bringToFront = useCallback((docId) => { + const bringToFront = useCallback((docId: Identifier | null | undefined) => { engine.bringToFront(docId); }, [engine]); @@ -156,11 +322,11 @@ const DesktopWorkspace = ({ engine.recalcVisibleDocIds(); }, [engine]); - const setDraggingId = useCallback((value) => { + const setDraggingId = useCallback((value: string | number | null) => { engine.setDraggingId(value); }, [engine]); - const applySnapshotDimensions = useCallback((docKey, snapshot) => { + const applySnapshotDimensions = useCallback((docKey: string, snapshot: NavigatorSnapshot | null) => { const width = Number(snapshot?.width); const height = Number(snapshot?.height); if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { @@ -180,7 +346,7 @@ const DesktopWorkspace = ({ setDocSizeVersion((value) => value + 1); }, []); const handleNavigatorSnapshot = useCallback( - (docId, snapshot) => { + (docId: Identifier | null, snapshot: NavigatorSnapshot | null) => { const docKey = docId != null ? String(docId) : null; if (!docKey) { return; @@ -222,11 +388,11 @@ const DesktopWorkspace = ({ }, [applySnapshotDimensions], ); - const activeTagSet = useMemo(() => { + const activeTagSet = useMemo>(() => { if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) { return new Set(); } - const set = new Set(); + const set = new Set(); activeTagIds.forEach((id) => { if (id != null) { set.add(String(id)); @@ -251,20 +417,20 @@ const DesktopWorkspace = ({ commitSize(); - if (!('ResizeObserver' in window)) { + if (typeof window.ResizeObserver === 'undefined') { window.addEventListener('resize', commitSize); return () => { window.removeEventListener('resize', commitSize); }; } - const observer = new ResizeObserver(commitSize); + const observer = new window.ResizeObserver(commitSize); observer.observe(container); return () => observer.disconnect(); }, [engine]); const resolvePreviewDimensions = useCallback( - (doc) => { + (doc: DeskDocument | null | undefined): PreviewMetadataEntry | null => { if (!doc?.id) { return null; } @@ -299,7 +465,7 @@ const DesktopWorkspace = ({ const focusTarget = () => { try { canvas.focus({ preventScroll: true }); - } catch (error) { + } catch (error: unknown) { if (DEBUG_FOCUS) { void error; } @@ -386,7 +552,7 @@ const DesktopWorkspace = ({ } }, [items, previewMetadata]); - const overlayDisplay = useMemo(() => { + const overlayDisplay = useMemo(() => { if (!overlayDocId) { return null; } @@ -395,7 +561,7 @@ const DesktopWorkspace = ({ return null; } const doc = documentLookup.get(overlayDocId); - const alt = snapshot.alt || doc?.title; + const alt = snapshot.alt || (doc?.title as string | undefined); return { url: snapshot.url, alt, @@ -421,7 +587,7 @@ const DesktopWorkspace = ({ }, [overlayDocId, documentLookup]); const resolveBaseMetrics = useCallback( - (doc, cardWidth, cardHeight) => { + (doc: DeskDocument | null | undefined, cardWidth: number, cardHeight: number) => { const previewDims = doc ? resolvePreviewDimensions(doc) : null; if (previewDims?.width && previewDims?.height) { const baseWidth = Math.max(previewDims.width, cardWidth); @@ -452,7 +618,7 @@ const DesktopWorkspace = ({ }, [draggingId, items, setDraggingId]); const openOverlayForDoc = useCallback( - (docId, originInfo = null) => { + (docId: Identifier | null | undefined, originInfo: OverlayOriginTransform | null = null) => { if (!docId) { return; } @@ -462,7 +628,9 @@ const DesktopWorkspace = ({ return; } const container = itemRefs.current.get(docKey); - const imageNode = container?.querySelector?.('.desk-item__card img'); + const imageNode = container + ? container.querySelector('.desk-item__card img') + : null; if (!container || !imageNode) { return; } @@ -514,7 +682,7 @@ const DesktopWorkspace = ({ ], ); - const dragSettings = useMemo( + const dragSettings = useMemo( () => ({ canvasPadding: DESK_CANVAS_PADDING, defaultCanvasWidth: DESK_DEFAULT_CANVAS_WIDTH, @@ -524,7 +692,7 @@ const DesktopWorkspace = ({ [], ); - const viewProps = useMemo( + const viewProps = useMemo( () => ({ engine, items, @@ -633,7 +801,7 @@ const DesktopWorkspace = ({ return ; }; -const DesktopWorkspaceView = ({ +function DesktopWorkspaceView({ engine, items, containerRef, @@ -684,7 +852,7 @@ const DesktopWorkspaceView = ({ onInspectDocument, markLayoutDirty, dragTransformsRef, -}) => { +}: DesktopWorkspaceViewProps) { const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag({ engine, @@ -705,7 +873,12 @@ const DesktopWorkspaceView = ({ onDocumentStackSelect, selectedDocumentIds, markLayoutDirty, - }); + }) as { + handlePointerDown: React.PointerEventHandler; + handlePointerMove: React.PointerEventHandler; + handlePointerUp: React.PointerEventHandler; + handlePointerCancel: React.PointerEventHandler; + }; const { getCardPointerHandlers, handleShellKeyDown, focusShell } = useDeskPointer({ containerRef, @@ -725,7 +898,11 @@ const DesktopWorkspaceView = ({ detailPanelOpen, onCloseDetailPanel, openOverlayForDoc, - }); + }) as { + getCardPointerHandlers: (doc: DeskDocument) => React.HTMLAttributes; + handleShellKeyDown: React.KeyboardEventHandler; + focusShell: () => void; + }; useEffect(() => { focusShell(); @@ -745,7 +922,7 @@ const DesktopWorkspaceView = ({ - const allSizesReady = items.every((doc) => ensureDocumentSize(doc)); + const allSizesReady = items.every((doc) => Boolean(ensureDocumentSize(doc))); return ( <> @@ -780,7 +957,7 @@ const DesktopWorkspaceView = ({

No documents to show here yet. Drop files to make this space come alive.

) : ( - items.map((doc) => { + items.map((doc, index) => { const sizeInfo = ensureDocumentSize(doc); if (!sizeInfo) { return null; @@ -797,10 +974,12 @@ const DesktopWorkspaceView = ({ if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { return null; } + const resolvedCenterX = centerX as number; + const resolvedCenterY = centerY as number; const rotation = dragOverride?.rotation ?? layout?.rotation ?? 0; const scale = dragOverride?.scale ?? 1; - const originX = centerX - cardWidth / 2; - const originY = centerY - cardHeight / 2; + const originX = resolvedCenterX - cardWidth / 2; + const originY = resolvedCenterY - cardHeight / 2; const transform = formatTransform( Math.round(originX), Math.round(originY), @@ -816,16 +995,19 @@ const DesktopWorkspaceView = ({ const shouldLoad = docKey ? visibleDocIds.has(docKey) : false; const dragging = docKey ? draggingId === docKey : false; const docTagKeys = Array.isArray(doc?.tags) - ? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean) + ? doc.tags + .map((tag) => (tag?.id != null ? String(tag.id) : null)) + .filter((id): id is string => Boolean(id)) : []; const matchesFilter = activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key)); const dropActive = docKey ? tagDropTargetId === docKey : false; const dropPending = docKey ? pendingTagDocId === docKey : false; - const isSelected = selectedDocumentIds.includes(doc.id); + const docId = doc?.id ?? null; + const isSelected = docId != null ? selectedDocumentIds.includes(docId) : false; const docTagTokens = docTagKeys.join(' '); - const cardPointerHandlers = getCardPointerHandlers(doc); - const registerNode = (node) => { + const cardPointerHandlers = getCardPointerHandlers(doc) as React.HTMLAttributes; + const registerNode = (node: HTMLDivElement | null) => { if (!docKey) { return; } @@ -838,7 +1020,7 @@ const DesktopWorkspaceView = ({ return ( ); -}; +} export default DesktopWorkspace; diff --git a/frontend/src/desktop/createDesktopSurface.js b/frontend/src/desktop/createDesktopSurface.tsx similarity index 85% rename from frontend/src/desktop/createDesktopSurface.js rename to frontend/src/desktop/createDesktopSurface.tsx index 2b1dd9a..a16fb9e 100644 --- a/frontend/src/desktop/createDesktopSurface.js +++ b/frontend/src/desktop/createDesktopSurface.tsx @@ -1,10 +1,34 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import SelectionFloatingActions from '../documents/SelectionFloatingActions'; import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel'; import createWorkspaceSurfaceConfig from '../documents/workspaceHeader'; import DocumentViewerPanel from '../preview/DocumentViewerPanel'; import DesktopWorkspace from './DesktopWorkspace'; +type WorkspaceProps = Record; + +interface Breadcrumb { + id?: string | number; + name?: string; + label?: string; + title?: string; +} + +interface DetailProps extends Record { + onClose?: () => void; + onOpenPreview?: () => void; + tags?: unknown; +} + +interface CreateDesktopSurfaceArgs { + workspaceProps?: WorkspaceProps | null; + renderSidebarToggle?: () => ReactNode; + parentBreadcrumb?: Breadcrumb | null; + onNavigateParent?: () => void; + detailProps?: DetailProps | null; + detailOpen?: boolean; +} + const createDesktopSurface = ({ workspaceProps, renderSidebarToggle, @@ -12,7 +36,7 @@ const createDesktopSurface = ({ onNavigateParent, detailProps = null, detailOpen = false, -}) => { +}: CreateDesktopSurfaceArgs) => { if (!workspaceProps) { return null; } diff --git a/frontend/src/desktop/db.js b/frontend/src/desktop/db.ts similarity index 53% rename from frontend/src/desktop/db.js rename to frontend/src/desktop/db.ts index e6a541e..b7c7b79 100644 --- a/frontend/src/desktop/db.js +++ b/frontend/src/desktop/db.ts @@ -2,15 +2,15 @@ const DB_NAME = 'papercrate_desk'; const DB_VERSION = 1; const LAYOUT_STORE = 'layouts'; -const currentDbPromise = { value: null }; +const currentDbPromise: { value: Promise | null } = { value: null }; -const openDatabase = () => { +const openDatabase = (): Promise => { if (currentDbPromise.value) { - return currentDbPromise.value; + return currentDbPromise.value as Promise; } currentDbPromise.value = new Promise((resolve, reject) => { - const dbApi = window.indexedDB; + const dbApi = typeof window !== 'undefined' ? window.indexedDB : null; if (!dbApi) { reject(new Error('IndexedDB not available')); return; @@ -39,51 +39,56 @@ const openDatabase = () => { }; }); - return currentDbPromise.value; + return currentDbPromise.value as Promise; }; -const requestToPromise = (request, defaultValue) => new Promise((resolve, reject) => { - request.onsuccess = () => { - const { result } = request; - resolve(result ?? defaultValue); - }; - request.onerror = () => { - reject(request.error || new Error('IndexedDB request failed')); - }; -}); +const requestToPromise = (request: IDBRequest, defaultValue: T): Promise => + new Promise((resolve, reject) => { + request.onsuccess = () => { + const { result } = request; + resolve(result ?? defaultValue); + }; + request.onerror = () => { + reject(request.error || new Error('IndexedDB request failed')); + }; + }); -const iterateCursor = (request, iteratee) => new Promise((resolve, reject) => { - request.onsuccess = (event) => { - const cursor = event.target.result; - if (!cursor) { +const iterateCursor = (request: IDBRequest, iteratee: (cursor: IDBCursorWithValue) => void) => + new Promise((resolve, reject) => { + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).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((resolve, reject) => { + transaction.oncomplete = () => { resolve(); - return; - } - try { - iteratee(cursor); - cursor.continue(); - } catch (error) { - reject(error); - } - }; - request.onerror = () => { - reject(request.error || new Error('IndexedDB cursor failed')); - }; -}); + }; + transaction.onerror = () => { + reject(transaction.error || new Error('IndexedDB transaction failed')); + }; + transaction.onabort = () => { + reject(transaction.error || new Error('IndexedDB transaction aborted')); + }; + }); -const transactionComplete = (transaction) => new Promise((resolve, reject) => { - transaction.oncomplete = () => { - resolve(); - }; - transaction.onerror = () => { - reject(transaction.error || new Error('IndexedDB transaction failed')); - }; - transaction.onabort = () => { - reject(transaction.error || new Error('IndexedDB transaction aborted')); - }; -}); +type TransactionMode = 'readonly' | 'readwrite' | 'versionchange'; -const withStore = async (mode, handler) => { +const withStore = async (mode: TransactionMode, handler: (store: IDBObjectStore, tx: IDBTransaction) => Promise | T): Promise => { const db = await openDatabase(); const transaction = db.transaction(LAYOUT_STORE, mode); const store = transaction.objectStore(LAYOUT_STORE); @@ -101,13 +106,24 @@ const withStore = async (mode, handler) => { try { await done; } catch { - // noop – prefer original error + // ignore } throw error; } }; -export const fetchLayoutRecords = async ({ tenantId, viewId }) => { +interface LayoutRecord { + tenantId: string | number; + viewId: string | number; + documentId: string | number; + centerX?: number; + centerY?: number; + rotation?: number; + zIndex?: number; + updatedAt?: number; +} + +export const fetchLayoutRecords = async ({ tenantId, viewId }: { tenantId?: string | number; viewId?: string | number }): Promise => { if (!tenantId || !viewId) { return []; } @@ -123,7 +139,7 @@ export const fetchLayoutRecords = async ({ tenantId, viewId }) => { } }; -export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => { +export const upsertLayoutRecords = async ({ tenantId, viewId, entries }: { tenantId?: string | number; viewId?: string | number; entries?: Array<{ documentId?: string | number; centerX?: number; centerY?: number; rotation?: number; zIndex?: number; updatedAt?: number }> }) => { if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) { return; } @@ -144,7 +160,7 @@ export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => { rotation: Number(entry.rotation) || 0, zIndex: Number(entry.zIndex) || 0, updatedAt: entry.updatedAt || timestamp, - }); + } satisfies LayoutRecord); }); }); } catch (error) { @@ -152,7 +168,7 @@ export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => { } }; -export const deleteTenantLayouts = async (tenantId) => { +export const deleteTenantLayouts = async (tenantId?: string | number) => { if (!tenantId) { return; } @@ -169,15 +185,17 @@ export const deleteTenantLayouts = async (tenantId) => { } }; -export const closeDeskDatabase = () => { +export const closeDeskDatabase = (): void => { if (!currentDbPromise.value) { return; } currentDbPromise.value = currentDbPromise.value.then((db) => { - try { - db.close(); - } catch (error) { - console.warn('[desk] Failed to close IndexedDB', error); + if (db) { + try { + db.close(); + } catch (error) { + console.warn('[desk] Failed to close IndexedDB', error); + } } return null; }); diff --git a/frontend/src/desktop/events.js b/frontend/src/desktop/events.ts similarity index 50% rename from frontend/src/desktop/events.js rename to frontend/src/desktop/events.ts index 6ff187e..c1ece50 100644 --- a/frontend/src/desktop/events.js +++ b/frontend/src/desktop/events.ts @@ -1,4 +1,9 @@ -export const preventAll = (event) => { +import type { PointerEvent as ReactPointerEvent } from 'react'; + +type PointerLikeEvent = MouseEvent & { pageX?: number; pageY?: number }; +type PreventableEvent = Event | ReactPointerEvent | { preventDefault?: () => void; stopPropagation?: () => void }; + +export const preventAll = (event?: PreventableEvent | null): void => { if (!event) { return; } @@ -14,9 +19,15 @@ export const preventAll = (event) => { } }; -export const safeInvoke = (fn, ...args) => fn?.(...args); +type AnyFn = (...args: unknown[]) => unknown; -export const getPointerPosition = (event, { fallbackToPage = true } = {}) => { +export const safeInvoke = (fn: Fn | null | undefined, ...args: Parameters): ReturnType | undefined => + (fn ? fn(...args) : undefined); + +export const getPointerPosition = ( + event?: PointerLikeEvent | null, + { fallbackToPage = true }: { fallbackToPage?: boolean } = {}, +): { x: number; y: number } => { if (!event) { return { x: 0, y: 0 }; } diff --git a/frontend/src/desktop/hooks/usePreviewMetadata.js b/frontend/src/desktop/hooks/usePreviewMetadata.ts similarity index 66% rename from frontend/src/desktop/hooks/usePreviewMetadata.js rename to frontend/src/desktop/hooks/usePreviewMetadata.ts index 2fa9fd7..9ea4939 100644 --- a/frontend/src/desktop/hooks/usePreviewMetadata.js +++ b/frontend/src/desktop/hooks/usePreviewMetadata.ts @@ -1,8 +1,32 @@ import { useEffect, useState } from 'react'; import { createAssetView } from '../../asset_manager'; -const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { - const [metadataMap, setMetadataMap] = useState(() => new Map()); +interface DocumentLike { + id?: string | number; + current_version?: unknown; + tags?: unknown; +} + +interface AssetLike { + id?: string | number; + [key: string]: unknown; +} + +interface PreviewMetadataEntry { + docId: string; + width: number; + height: number; +} + +type GetDocumentAsset = (doc: DocumentLike, type: string) => AssetLike | null | undefined; +type EnsureAssetUrl = (docId: string | number, asset: AssetLike, options?: { start?: number; limit?: number }) => Promise; + +const usePreviewMetadata = ( + documents: DocumentLike[] | null | undefined, + getDocumentAsset?: GetDocumentAsset, + ensureAssetUrl?: EnsureAssetUrl, +) => { + const [metadataMap, setMetadataMap] = useState>(() => new Map()); useEffect(() => { let cancelled = false; @@ -14,19 +38,19 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { }; } - const fetchMetadataForDoc = async (doc) => { + const fetchMetadataForDoc = async (doc: DocumentLike) => { if (!doc?.id) { return null; } const docId = String(doc.id); - const resolveAsset = (type) => getDocumentAsset?.(doc, type) ?? null; + const resolveAsset = (type: string) => getDocumentAsset?.(doc, type) ?? null; let asset = resolveAsset('preview') || resolveAsset('thumbnail'); let view = createAssetView(asset); let metadata = view.getPrimaryMetadata(); - const hasDimensions = (meta) => + const hasDimensions = (meta: { width?: number | string; height?: number | string } | null | undefined) => Number.isFinite(Number(meta?.width)) && Number.isFinite(Number(meta?.height)) && Number(meta.width) > 0 && diff --git a/frontend/src/desktop/math.js b/frontend/src/desktop/math.js deleted file mode 100644 index 7c26bad..0000000 --- a/frontend/src/desktop/math.js +++ /dev/null @@ -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})`; diff --git a/frontend/src/desktop/math.ts b/frontend/src/desktop/math.ts new file mode 100644 index 0000000..fcf93d7 --- /dev/null +++ b/frontend/src/desktop/math.ts @@ -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})`; diff --git a/frontend/src/desktop/pointer/pointerUtils.js b/frontend/src/desktop/pointer/pointerUtils.ts similarity index 66% rename from frontend/src/desktop/pointer/pointerUtils.js rename to frontend/src/desktop/pointer/pointerUtils.ts index 160d78a..4068a1f 100644 --- a/frontend/src/desktop/pointer/pointerUtils.js +++ b/frontend/src/desktop/pointer/pointerUtils.ts @@ -1,4 +1,4 @@ -import { safeInvoke } from '../events.js'; +import { safeInvoke } from '../events'; export const CLICK_ACTIONS = { selectSingle: 'selectSingle', @@ -6,20 +6,52 @@ export const CLICK_ACTIONS = { addCard: 'addCard', addStack: 'addStack', none: 'none', -}; +} as const; export const DRAG_ACTIONS = { dragSelectSingle: 'dragSelectSingle', dragSelection: 'dragSelection', dragSelectStack: 'dragSelectStack', none: 'none', -}; +} as const; + +export type ClickAction = (typeof CLICK_ACTIONS)[keyof typeof CLICK_ACTIONS]; +export type DragAction = (typeof DRAG_ACTIONS)[keyof typeof DRAG_ACTIONS]; export const STACK_HIT_EPSILON = 4; export const POINTER_DRAG_THRESHOLD_SQUARED = 16; export const LONG_PRESS_DURATION_MS = 450; -export const withinThreshold = (dx, dy, thresholdSquared) => (dx * dx + dy * dy) <= thresholdSquared; +export const withinThreshold = (dx: number, dy: number, thresholdSquared: number): boolean => (dx * dx + dy * dy) <= thresholdSquared; + +interface PointerIntentArgs { + doc: { id: string | number }; + entryDescriptor: unknown; + selectedDocumentIds: Array; + metaKey: boolean; + pointerButton?: number; + pointerType?: string; + stackHits?: string[] | null; +} + +export interface PointerIntent { + docId: string | number; + entryDescriptor: unknown; + pointerType?: string; + pointerButton?: number; + selectedAtDown: boolean; + selectionCountAtDown: number; + metaKey: boolean; + clickAction: ClickAction; + dragAction: DragAction; + stackDocIdsForDrag: string[] | null; + stackDocIdsForClick: string[] | null; + stackReplaceOnClick: boolean; + stackReplaceOnDrag: boolean; + clickSelectionApplied: boolean; + stackSelectionApplied: boolean; + longPressTriggered: boolean; +} export const createPointerIntent = ({ doc, @@ -29,7 +61,7 @@ export const createPointerIntent = ({ pointerButton, pointerType, stackHits, -}) => { +}: PointerIntentArgs): PointerIntent => { const alreadySelected = selectedDocumentIds.includes(doc.id); const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; @@ -74,7 +106,12 @@ export const createPointerIntent = ({ }; }; -export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => { +export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }: { + intent: PointerIntent; + event?: unknown; + onEntryPointer?: (descriptor: unknown, event?: unknown) => void; + onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void; +}) => { switch (intent.clickAction) { case CLICK_ACTIONS.selectSingle: case CLICK_ACTIONS.addCard: @@ -100,7 +137,12 @@ export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDoc } }; -export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => { +export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }: { + intent: PointerIntent; + event?: unknown; + onEntryPointer?: (descriptor: unknown, event?: unknown) => void; + onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void; +}) => { if (!intent || intent.clickSelectionApplied) { return; } @@ -108,7 +150,12 @@ export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocume applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect }); }; -export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }) => { +export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }: { + intent: PointerIntent; + stackDocIds?: string[] | null; + syntheticEvent?: unknown; + onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void; +}) => { if (!intent) { return; } diff --git a/frontend/src/desktop/pointer/useDeskPointer.js b/frontend/src/desktop/pointer/useDeskPointer.js index c932bf2..4feff5d 100644 --- a/frontend/src/desktop/pointer/useDeskPointer.js +++ b/frontend/src/desktop/pointer/useDeskPointer.js @@ -14,7 +14,7 @@ import { finalizeClickSelection, withinThreshold, } from './pointerUtils'; -import { getPointerPosition, safeInvoke } from '../events.js'; +import { getPointerPosition, safeInvoke } from '../events'; const buildEntryDescriptor = (docId) => ({ type: 'document', diff --git a/frontend/src/desktop/tags/useDeskTagInteractions.js b/frontend/src/desktop/tags/useDeskTagInteractions.js index cc21235..674c16b 100644 --- a/frontend/src/desktop/tags/useDeskTagInteractions.js +++ b/frontend/src/desktop/tags/useDeskTagInteractions.js @@ -3,7 +3,7 @@ import { useEffect, useRef, } from 'react'; -import { getPointerPosition, preventAll, safeInvoke } from '../events.js'; +import { getPointerPosition, preventAll, safeInvoke } from '../events'; import { isTagTransferEvent, parseTagTransferPayload, diff --git a/frontend/src/desktop/useDeskWorkspaceProps.js b/frontend/src/desktop/useDeskWorkspaceProps.ts similarity index 61% rename from frontend/src/desktop/useDeskWorkspaceProps.js rename to frontend/src/desktop/useDeskWorkspaceProps.ts index 8d8f634..bf9f51c 100644 --- a/frontend/src/desktop/useDeskWorkspaceProps.js +++ b/frontend/src/desktop/useDeskWorkspaceProps.ts @@ -1,11 +1,75 @@ import { useCallback, useMemo } from 'react'; +type Identifier = string | number; + +type DocumentEntry = { id?: Identifier } & Record; + +type WorkspaceViewMode = 'desk' | 'grid' | 'list' | string; + +type DeskDocumentStackSelectHandler = (docIds: Identifier[]) => void; + +type ResolveRowKey = (id: Identifier) => Identifier | string | null; + +type ApplySelection = ( + keys: Array, + options: { anchor?: Identifier | string | null; interactedKeys?: Array }, +) => void; + +interface UseDeskWorkspacePropsArgs { + documents?: DocumentEntry[]; + searchResults?: DocumentEntry[]; + breadcrumbs?: unknown[]; + currentFolderName?: string | null; + documentsViewMode?: WorkspaceViewMode; + handleDocumentsViewModeChange?: (mode: WorkspaceViewMode) => void; + handleDeskExit?: () => void; + refreshCurrentFolder?: () => Promise | 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; + selectionAnchorRef: { current: Identifier | string | null }; + applySelection: ApplySelection; + resolveDocumentRowKey: ResolveRowKey; + showingSearchResults?: boolean; + searchQuery?: string; + activeCorrespondentFilters?: Identifier[]; + selectedFolder?: Identifier | string | null; + openDetailPanel?: (args: { documentIds: Identifier[] }) => void; +} + const useDeskWorkspaceProps = ({ documents, searchResults, breadcrumbs, currentFolderName, - documentsViewMode, + documentsViewMode = 'desk', handleDocumentsViewModeChange, handleDeskExit, refreshCurrentFolder, @@ -23,7 +87,7 @@ const useDeskWorkspaceProps = ({ handleTagRemove, ensureAssetUrl, getDocumentAsset, - activeTagFilters, + activeTagFilters = [], handleDeleteSelection, tags, correspondents, @@ -38,17 +102,17 @@ const useDeskWorkspaceProps = ({ moveDocumentsToFolder, searchIncludeDescendants, toggleSearchIncludeDescendants, - selectedEntries, + selectedEntries = [], selectionAnchorRef, applySelection, resolveDocumentRowKey, - showingSearchResults, - searchQuery, - activeCorrespondentFilters, + showingSearchResults = false, + searchQuery = '', + activeCorrespondentFilters = [], selectedFolder, openDetailPanel, -}) => { - const handleDeskDocumentStackSelect = useCallback( +}: UseDeskWorkspacePropsArgs) => { + const handleDeskDocumentStackSelect: DeskDocumentStackSelectHandler = useCallback( (docIds) => { if (!Array.isArray(docIds) || docIds.length === 0) { return; @@ -56,7 +120,7 @@ const useDeskWorkspaceProps = ({ const rowKeys = docIds .map((id) => resolveDocumentRowKey(id)) - .filter(Boolean); + .filter((value): value is Identifier | string => Boolean(value)); if (!rowKeys.length) { return; @@ -69,9 +133,9 @@ const useDeskWorkspaceProps = ({ } }); - const anchor = rowKeys[0] + const anchor = (rowKeys[0] || selectionAnchorRef.current - || nextKeys[nextKeys.length - 1]; + || nextKeys[nextKeys.length - 1]) as Identifier | string | null; applySelection(nextKeys, { anchor, @@ -82,13 +146,13 @@ const useDeskWorkspaceProps = ({ ); const handleDeskDocumentOpen = useCallback( - (docId, { useSelection = false } = {}) => { + (docId: Identifier | undefined, { useSelection = false }: { useSelection?: boolean } = {}) => { const selectionDocIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : []; - let targetIds = []; + let targetIds: Identifier[] = []; - if ((useSelection || selectionDocIds.includes(docId)) && selectionDocIds.length) { + if ((useSelection || selectionDocIds.includes(docId as Identifier)) && selectionDocIds.length) { targetIds = selectionDocIds; } else if (selectionDocIds.length) { targetIds = selectionDocIds; @@ -100,7 +164,7 @@ const useDeskWorkspaceProps = ({ return; } - openDetailPanel({ documentIds: targetIds }); + openDetailPanel?.({ documentIds: targetIds }); }, [openDetailPanel, selectedDocumentIds], ); diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.ts similarity index 79% rename from frontend/src/desktop/useDocumentDrag.js rename to frontend/src/desktop/useDocumentDrag.ts index db4dacd..9da034f 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.ts @@ -1,26 +1,179 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { + useCallback, + useEffect, + useRef, + type MutableRefObject, + type RefObject, +} from 'react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; import { preventAll, safeInvoke } from './events'; import { clamp } from './math'; import usePointerTap from '../ui/usePointerTap'; -import { MIN_TIMESTEP, MAX_TIMESTEP, applyDomTransform } from './workspaceEngine'; +import { + MIN_TIMESTEP, + MAX_TIMESTEP, + applyDomTransform, + type WorkspaceEngine, +} from './workspaceEngine'; + +type Identifier = string | number; + +interface DocumentLike { + id?: Identifier | null; + title?: string; + [key: string]: unknown; +} + +interface DocumentSizeInfo { + width: number; + height: number; +} + +interface LayoutEntry { + centerX?: number; + centerY?: number; + rotation?: number; + z?: number; + width?: number; + height?: number; +} + +interface DragTransform { + centerX: number; + centerY: number; + rotation: number; + width?: number; + height?: number; + scale?: number; +} + +type EngineDragState = Parameters[0]; +type EngineGroupItem = NonNullable[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; + stackSelectionApplied?: boolean; + wasSelected?: boolean; + modifierActive?: boolean; + stackReplace?: boolean; +} + +interface UseDocumentDragOptions { + engine?: WorkspaceEngine | null; + layoutRef: MutableRefObject>; + dragTransformsRef: MutableRefObject>; + itemRefs: MutableRefObject>; + documentLookup: Map; + 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; + onInspectDocument?: (docId: Identifier | null | undefined, event?: PointerEvent | ReactPointerEvent) => void; + onDocumentStackSelect?: ( + docIds: Identifier[], + event: PointerEvent | ReactPointerEvent, + options?: { replace?: boolean }, + ) => void; + selectedDocumentIds?: Array; + markLayoutDirty?: () => void; +} + +type EngineInertiaState = Parameters[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; + +interface DragTapMetadata { + docId: Identifier | null; + originInfo?: { rotation: number; scale: number; width: number; height: number }; + docTitle: string; +} const DRAG_HYSTERESIS_PX = 4; const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX; const EDGE_COLLISION_THRESHOLD = 0.5; -const getEventTargetElement = (event) => { +const getEventTargetElement = (event?: PointerEventLike | null): Element | null => { if (!event) { return null; } - const ElementCtor = window.Element; + const ElementCtor = typeof window !== 'undefined' ? window.Element : null; if (!ElementCtor) { return null; } - const candidate = event.target || (event.nativeEvent ? event.nativeEvent.target : null); + const nativeEvent = 'nativeEvent' in event ? (event as ReactPointerEvent).nativeEvent : null; + const candidate = (event.target as Element | null) || (nativeEvent ? (nativeEvent.target as Element | null) : null); return candidate instanceof ElementCtor ? candidate : null; }; -const useDocumentDrag = (options = {}) => { +const useDocumentDrag = (options: UseDocumentDragOptions) => { const { engine, layoutRef, @@ -35,13 +188,16 @@ const useDocumentDrag = (options = {}) => { openOverlayForDoc, recalcVisibleDocIds, settings, - containerRef, + containerRef: providedContainerRef, onInspectDocument, onDocumentStackSelect, - selectedDocumentIds, + selectedDocumentIds = [], markLayoutDirty, } = options; + const fallbackContainerRef = useRef(null); + const containerRef = providedContainerRef ?? fallbackContainerRef; + const { canvasPadding = 24, defaultCanvasWidth = 1024, @@ -56,23 +212,23 @@ const useDocumentDrag = (options = {}) => { [engine], ); - const tapHandler = usePointerTap({ + const tapHandler = usePointerTap({ delay: 220, onSingle: () => {}, onDouble: ({ data, event }) => { - if (!data || !data.docId) { + if (!data?.docId) { return; } if (event?.altKey) { - openOverlayForDoc(data.docId, data.originInfo); + openOverlayForDoc?.(data.docId, data.originInfo); return; } onInspectDocument?.(data.docId, event); }, }); - const dragStateRef = useRef(null); + const dragStateRef = useRef(null); - const setDragTransform = useCallback((docKey, transform) => { + const setDragTransform = useCallback((docKey: Identifier | null | undefined, transform: DragTransform | null) => { if (!docKey) { return; } @@ -80,7 +236,11 @@ const useDocumentDrag = (options = {}) => { if (!map) { return; } - map.set(String(docKey), transform); + if (transform) { + map.set(String(docKey), transform); + } else { + map.delete(String(docKey)); + } }, [dragTransformsRef]); const clearDragTransforms = useCallback(() => { @@ -91,13 +251,15 @@ const useDocumentDrag = (options = {}) => { map.clear(); }, [dragTransformsRef]); - const commitActiveDragTransforms = useCallback((docIds = null) => { + const commitActiveDragTransforms = useCallback((docIds: Array | null = null) => { const map = dragTransformsRef?.current; if (!map || !map.size) { return; } const keys = Array.isArray(docIds) && docIds.length - ? docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean) + ? docIds + .map((id) => (id != null ? String(id) : null)) + .filter((value): value is string => Boolean(value)) : Array.from(map.keys()); keys.forEach((key) => { const transform = map.get(key); @@ -116,7 +278,7 @@ const useDocumentDrag = (options = {}) => { }, [dragTransformsRef, layoutRef, markLayoutDirty]); const finishDrag = useCallback( - (pointerId, { clearTransforms = true } = {}) => { + (pointerId: number, { clearTransforms = true }: { clearTransforms?: boolean } = {}) => { const state = dragStateRef.current; if (state && state.pointerId === pointerId) { const capturedTarget = state.capturedTarget; @@ -141,7 +303,7 @@ const useDocumentDrag = (options = {}) => { ); const handlePointerDown = useCallback( - (event, docIdInput, options = {}) => { + (event: PointerEventLike, docIdInput?: Identifier | null, options: PointerDownOptions = {}) => { const targetElement = getEventTargetElement(event); if (targetElement?.closest && targetElement.closest('[data-desk-tag-chip="true"]')) { return; @@ -165,7 +327,7 @@ const useDocumentDrag = (options = {}) => { const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw) ? stackDocIdsOptionRaw .map((value) => (value != null ? String(value) : null)) - .filter(Boolean) + .filter((value): value is string => Boolean(value)) : null; const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied); const wasSelectedAtPointerDown = Boolean(options?.wasSelected); @@ -173,8 +335,10 @@ const useDocumentDrag = (options = {}) => { options?.modifierActive ?? Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); const stackReplace = Boolean(options?.stackReplace); - let selectionIds = Array.isArray(selectedDocumentIds) - ? selectedDocumentIds.map((id) => String(id)) + let selectionIds: string[] = Array.isArray(selectedDocumentIds) + ? selectedDocumentIds + .map((id) => (id != null ? String(id) : null)) + .filter((id): id is string => Boolean(id)) : []; if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) { @@ -196,9 +360,7 @@ const useDocumentDrag = (options = {}) => { selectionIds = [...selectionIds, docKey]; } - selectionIds = selectionIds - .map((id) => String(id)) - .filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id)); + selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id)); if (!selectionIds.includes(docKey)) { selectionIds.unshift(docKey); @@ -266,7 +428,7 @@ const useDocumentDrag = (options = {}) => { } } - const containerRect = containerRef?.current?.getBoundingClientRect?.() || null; + const containerRect = containerRef.current?.getBoundingClientRect?.() || null; const containerLeft = containerRect?.left || 0; const containerTop = containerRect?.top || 0; const pointerCanvasX = event.clientX - containerLeft; @@ -280,7 +442,7 @@ const useDocumentDrag = (options = {}) => { const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial; const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial; - const groupItems = selectionIds.map((id) => { + const groupItems: DragGroupItemInternal[] = selectionIds.map((id) => { const itemDoc = documentLookup.get(id); const itemSize = ensureDocumentSize(itemDoc) || sizeInfo; const itemWidth = itemSize.width || docWidth; @@ -307,7 +469,7 @@ const useDocumentDrag = (options = {}) => { initialRotation, displayRotation: initialRotation, targetRotation, - }; + } satisfies DragGroupItemInternal; }); const eventTimestamp = @@ -354,9 +516,12 @@ const useDocumentDrag = (options = {}) => { stackDocIds: hasStackSource ? stackDocIdsOption : null, stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource, stackReplace, - }; + } satisfies DragStateInternal; const state = dragStateRef.current; + if (!state) { + return; + } clearDragTransforms(); state.groupItems.forEach((item) => { @@ -416,7 +581,7 @@ const useDocumentDrag = (options = {}) => { ]); const handlePointerMove = useCallback( - (event) => { + (event: PointerEventLike) => { const state = dragStateRef.current; if (!state) { return; @@ -427,7 +592,7 @@ const useDocumentDrag = (options = {}) => { preventAll(event); if (state.isGroup) { - const containerRect = containerRef?.current?.getBoundingClientRect?.(); + const containerRect = containerRef.current?.getBoundingClientRect?.(); if (containerRect) { state.containerRectLeft = containerRect.left; state.containerRectTop = containerRect.top; @@ -449,7 +614,9 @@ const useDocumentDrag = (options = {}) => { && Array.isArray(state.stackDocIds) && state.stackDocIds.length > 0 ) { - safeInvoke(onDocumentStackSelect, state.stackDocIds, event, { replace: state.stackReplace }); + safeInvoke(onDocumentStackSelect, state.stackDocIds as Identifier[], event, { + replace: state.stackReplace, + }); state.stackSelectionApplied = true; } if (!state.groupElevated) { @@ -561,7 +728,7 @@ const useDocumentDrag = (options = {}) => { const halfWidth = docWidth / 2; const halfHeight = docHeight / 2; - const containerRect = containerRef?.current?.getBoundingClientRect?.(); + const containerRect = containerRef.current?.getBoundingClientRect?.(); if (containerRect) { state.containerRectLeft = containerRect.left; state.containerRectTop = containerRect.top; @@ -712,7 +879,7 @@ const useDocumentDrag = (options = {}) => { ); const handlePointerUp = useCallback( - (event) => { + (event: PointerEventLike) => { const state = dragStateRef.current; if (!state || state.pointerId !== event.pointerId) { finishDrag(event.pointerId); @@ -729,7 +896,7 @@ const useDocumentDrag = (options = {}) => { if (state.moved) { commitActiveDragTransforms([state.docKey]); - const inertiaState = { + const inertiaState: EngineInertiaState = { restRotation: state.restRotation, dynamicRotation: state.dynamicRotation, angularVelocity: state.angularVelocity, @@ -776,7 +943,7 @@ const useDocumentDrag = (options = {}) => { ); const handlePointerCancel = useCallback( - (event) => { + (event: PointerEventLike) => { const state = dragStateRef.current; if (state && state.pointerId === event.pointerId && state.moved) { if (state.isGroup) { @@ -788,7 +955,7 @@ const useDocumentDrag = (options = {}) => { } commitActiveDragTransforms([state.docKey]); - const inertiaState = { + const inertiaState: EngineInertiaState = { restRotation: state.restRotation, dynamicRotation: state.dynamicRotation, angularVelocity: state.angularVelocity, diff --git a/frontend/src/desktop/workspaceEngine.js b/frontend/src/desktop/workspaceEngine.ts similarity index 78% rename from frontend/src/desktop/workspaceEngine.js rename to frontend/src/desktop/workspaceEngine.ts index fb9ffe1..505b6f6 100644 --- a/frontend/src/desktop/workspaceEngine.js +++ b/frontend/src/desktop/workspaceEngine.ts @@ -1,5 +1,131 @@ -import { clamp, formatTransform } from './math.js'; -import { fetchLayoutRecords, upsertLayoutRecords } from './db.js'; +import { clamp, formatTransform } from './math'; +import { fetchLayoutRecords, upsertLayoutRecords } from './db'; + +type DocumentId = string; + +interface Point { + x: number; + y: number; +} + +type Polygon = Point[]; + +interface TransformOptions { + centerX?: number; + centerY?: number; + width?: number; + height?: number; + rotation?: number; + scale?: number; + zIndex?: number | null; +} + +interface CardDimensions { + width: number; + height: number; +} + +interface LayoutEntry { + centerX: number; + centerY: number; + rotation: number; + z: number; + width?: number; + height?: number; +} + +interface LayoutGenerationEntry { + id: string; + width: number; + height: number; + seedKey: string; +} + +interface LayoutGenerationOptions { + canvasWidth: number; + canvasHeight: number; + padding: number; + startZ?: number; + rotationRange?: number; + minSpacing?: number; + shelfWidth?: number; +} + +interface DocumentSize { + width: number; + height: number; +} + +interface BaseMetrics { + baseWidth: number; + baseHeight: number; + baseScale: number; +} + +interface DragGroupItem { + docId?: string | number | null; + width: number; + height: number; + currentCenterX?: number; + currentCenterY?: number; + displayRotation?: number; +} + +interface DragState { + docKey?: string | null; + dragScale?: number; + originCenterX?: number; + originCenterY?: number; + groupItems?: DragGroupItem[] | null; +} + +interface InertiaSimulationState { + docId: string; + restRotation: number; + rotation: number; + dynamicRotation: number; + angularVelocity: number; + width: number; + height: number; + dragScale?: number; + lastTimestamp: number; + frameId?: number; +} + +interface WorkspaceSnapshot { + layout: Map; + canvasSize: { width: number; height: number }; + visibleDocIds: Set; + draggingId: string | null; + tagDropTargetId: string | null; + pendingTagDocId: string | null; + pendingRemovalTag: unknown; + initialLoadDone: boolean; +} + +type WorkspaceSubscriber = () => void; + +type DeskDocument = { id?: string | number | null } & Record; + +type EnsureDocumentSize = (doc: DeskDocument) => DocumentSize | null | undefined; + +type ResolveBaseMetrics = () => BaseMetrics; + +interface ItemRefs { + current: Map; +} + +interface WorkspaceEngineOptions { + allowLayoutPersistence?: boolean; + tenantId?: string | null; + viewId?: string | null; +} + +type UseSyncExternalStoreHook = ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => State, + getServerSnapshot: () => State, +) => State; export const DESK_CANVAS_PADDING = 24; export const DESK_ROTATION_RANGE = 7; @@ -18,7 +144,7 @@ export const TORQUE_TO_ACCELERATION = 0.006; export const SETTLE_ANGULAR_VELOCITY = 1.2; export const applyDomTransform = ( - node, + node: HTMLElement | null | undefined, { centerX, centerY, @@ -27,8 +153,8 @@ export const applyDomTransform = ( rotation = 0, scale = 1, zIndex, - } = {}, -) => { + }: TransformOptions = {}, +): void => { if (!node) { return; } @@ -44,7 +170,7 @@ export const applyDomTransform = ( } }; -export const clampCardDimensions = (width, height) => { +export const clampCardDimensions = (width: number, height: number): CardDimensions | null => { const w = Number(width); const h = Number(height); @@ -56,7 +182,7 @@ export const clampCardDimensions = (width, height) => { const high = Math.min(DESK_CARD_MAX / w, DESK_CARD_MAX / h); const candidates = []; - const addCandidate = (scale) => { + const addCandidate = (scale: number) => { if (Number.isFinite(scale) && scale > 0) { candidates.push(scale); } @@ -66,7 +192,7 @@ export const clampCardDimensions = (width, height) => { addCandidate(low); addCandidate(high); - const best = candidates.reduce((acc, scale) => { + const best = candidates.reduce<{ scale: number; violation: number; deviation: number } | null>((acc, scale) => { const scaledWidth = w * scale; const scaledHeight = h * scale; const violation = Math.max( @@ -89,7 +215,7 @@ export const clampCardDimensions = (width, height) => { }; }; -export const computeFallbackCardSize = (docId) => { +export const computeFallbackCardSize = (docId: string | number): CardDimensions | null => { const baseSeed = seededRandom(`${docId}:fallback-size`); const aspectSeed = seededRandom(`${docId}:fallback-aspect`); @@ -105,7 +231,7 @@ export const computeFallbackCardSize = (docId) => { return clampCardDimensions(width, height); }; -function seededRandom(input) { +function seededRandom(input: unknown): number { const text = String(input); let hash = 2166136261; for (let index = 0; index < text.length; index += 1) { @@ -115,22 +241,25 @@ function seededRandom(input) { return (hash >>> 0) / 4294967295; } -function randomRangeFromSeed(seedKey, min, max) { +function randomRangeFromSeed(seedKey: string, min: number, max: number): number { const span = max - min; if (span <= 0) return min; const seed = seededRandom(seedKey); return min + seed * span; } -function buildKey(docId, suffix) { +function buildKey(docId: string | number, suffix: string): string { return `${docId}::${suffix}`; } -const signedDistanceToEdge = (edgeStart, edgeEnd, point) => +const signedDistanceToEdge = (edgeStart: Point, edgeEnd: Point, point: Point): number => (edgeEnd.x - edgeStart.x) * (point.y - edgeStart.y) - (edgeEnd.y - edgeStart.y) * (point.x - edgeStart.x); -const iterateEdges = (polygon, callback) => { +const iterateEdges = ( + polygon: Polygon, + callback: (current: Point, next: Point, index: number) => boolean | void, +): void => { if (!Array.isArray(polygon) || polygon.length === 0) { return; } @@ -143,7 +272,10 @@ const iterateEdges = (polygon, callback) => { } }; -const forEachVertex = (polygon, callback) => { +const forEachVertex = ( + polygon: Polygon, + callback: (current: Point, previous: Point, index: number) => boolean | void, +): void => { if (!Array.isArray(polygon) || polygon.length === 0) { return; } @@ -156,7 +288,7 @@ const forEachVertex = (polygon, callback) => { } }; -const lineIntersection = (p1, p2, cp1, cp2) => { +const lineIntersection = (p1: Point, p2: Point, cp1: Point, cp2: Point): Point => { const A1 = p2.y - p1.y; const B1 = p1.x - p2.x; const C1 = A1 * p1.x + B1 * p1.y; @@ -175,7 +307,7 @@ const lineIntersection = (p1, p2, cp1, cp2) => { }; }; -const clipPolygon = (subject, clipper) => { +const clipPolygon = (subject: Polygon, clipper: Polygon): Polygon => { if (!Array.isArray(subject) || !subject.length) { return []; } @@ -204,7 +336,7 @@ const clipPolygon = (subject, clipper) => { return output; }; -const isPointInsideConvex = (point, polygon) => { +const isPointInsideConvex = (point: Point, polygon: Polygon): boolean => { if (!polygon?.length) { return false; } @@ -229,7 +361,7 @@ const isPointInsideConvex = (point, polygon) => { return inside; }; -const polygonCentroid = (polygon) => { +const polygonCentroid = (polygon: Polygon): Point => { if (!polygon?.length) { return { x: 0, y: 0 }; } @@ -261,7 +393,7 @@ const polygonCentroid = (polygon) => { }; }; const generateInitialLayout = ( - entries, + entries: LayoutGenerationEntry[], { canvasWidth, canvasHeight, @@ -270,9 +402,9 @@ const generateInitialLayout = ( rotationRange = DESK_ROTATION_RANGE, minSpacing = 48, shelfWidth = 0, - }, -) => { - const layout = new Map(); + }: LayoutGenerationOptions, +): { layout: Map; maxZ: number } => { + const layout = new Map(); let currentZ = startZ; let maxZ = startZ; @@ -282,9 +414,9 @@ const generateInitialLayout = ( const shelfOffset = Math.max(shelfWidth, 0); const spacingBuffer = Math.max(minSpacing, 0); - const placed = []; + const placed: Array<{ x: number; y: number; radius: number }> = []; - const resolveBounds = (width, height) => { + const resolveBounds = (width: number, height: number) => { const halfWidth = width / 2; const halfHeight = height / 2; return { @@ -298,7 +430,7 @@ const generateInitialLayout = ( }; }; - const evaluateCandidateSpacing = (x, y, radius) => { + const evaluateCandidateSpacing = (x: number, y: number, radius: number) => { if (!placed.length) { return Number.POSITIVE_INFINITY; } @@ -381,11 +513,71 @@ const generateInitialLayout = ( }; export class WorkspaceEngine { + allowLayoutPersistence: boolean; + + tenantId: string | null; + + viewId: string | null; + + layout: Map; + + layoutSnapshot: Map; + + persistedLayout: Map; + + layoutDirty: boolean; + + zCounter: number; + + canvasSize: { width: number; height: number }; + + visibleDocIds: Set; + + draggingId: string | null; + + tagDropTargetId: string | null; + + pendingTagDocId: string | null; + + pendingRemovalTag: unknown; + + dragInProgress: boolean; + + activeDragDocIds: Set; + + pendingSnapshotSync: boolean; + + pendingPersistSync: boolean; + + persistDebounceId: number | null; + + items: DeskDocument[]; + + documentLookup: Map; + + ensureDocumentSize: EnsureDocumentSize; + + resolveBaseMetrics: ResolveBaseMetrics; + + snapshotCache: WorkspaceSnapshot; + + subscribers: Set; + + loadingPersisted: boolean; + + pendingPersistence: unknown; + + itemRefs: ItemRefs; + + inertiaAnimations: Map; + + initialLoadDone: boolean; + constructor({ allowLayoutPersistence = false, tenantId = null, viewId = null, - } = {}) { + }: WorkspaceEngineOptions = {}) { this.allowLayoutPersistence = allowLayoutPersistence; this.tenantId = tenantId; this.viewId = viewId; @@ -424,7 +616,7 @@ export class WorkspaceEngine { this.initialLoadDone = false; } - updateConfig({ allowLayoutPersistence, tenantId, viewId }) { + updateConfig({ allowLayoutPersistence, tenantId, viewId }: WorkspaceEngineOptions): void { const allowChanged = typeof allowLayoutPersistence === 'boolean' && allowLayoutPersistence !== this.allowLayoutPersistence; @@ -470,7 +662,7 @@ export class WorkspaceEngine { } } - setItems(items) { + setItems(items: DeskDocument[] | null | undefined): void { const normalized = Array.isArray(items) ? items : []; this.items = normalized; const canGenerateLayoutImmediately = @@ -484,28 +676,28 @@ export class WorkspaceEngine { this.recalcVisibleDocIds(); } - setDocumentLookup(map) { + setDocumentLookup(map: Map): void { this.documentLookup = map instanceof Map ? map : new Map(); this.recalcVisibleDocIds(); } - setEnsureDocumentSize(fn) { + setEnsureDocumentSize(fn: EnsureDocumentSize): void { if (typeof fn === 'function') { this.ensureDocumentSize = fn; } } - setResolveBaseMetrics(fn) { + setResolveBaseMetrics(fn: ResolveBaseMetrics): void { if (typeof fn === 'function') { this.resolveBaseMetrics = fn; } } - setItemRefs(ref) { + setItemRefs(ref: ItemRefs | null | undefined): void { this.itemRefs = ref || { current: new Map() }; } - setCanvasSize(size) { + setCanvasSize(size: { width?: number | null; height?: number | null }): void { const width = Number(size?.width) || 0; const height = Number(size?.height) || 0; if (this.canvasSize.width === width && this.canvasSize.height === height) { @@ -517,7 +709,7 @@ export class WorkspaceEngine { this.emit(); } - setDraggingId(docId) { + setDraggingId(docId: string | number | null): void { const normalized = docId != null ? String(docId) : null; if (this.draggingId === normalized) { return; @@ -526,7 +718,7 @@ export class WorkspaceEngine { this.emit(); } - beginDrag(docIds = []) { + beginDrag(docIds: Array = []): void { this.dragInProgress = true; if (Array.isArray(docIds)) { this.activeDragDocIds = new Set(docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean)); @@ -535,13 +727,13 @@ export class WorkspaceEngine { } } - endDrag() { + endDrag(): void { this.dragInProgress = false; this.activeDragDocIds.clear(); this.flushPendingLayoutOps(); } - flushPendingLayoutOps() { + flushPendingLayoutOps(): void { if (this.pendingSnapshotSync) { this.syncLayoutSnapshot(); } @@ -550,7 +742,7 @@ export class WorkspaceEngine { } } - setTagDropTargetId(docId) { + setTagDropTargetId(docId: string | number | null): void { const normalized = docId != null ? String(docId) : null; if (this.tagDropTargetId === normalized) { return; @@ -559,7 +751,7 @@ export class WorkspaceEngine { this.emit(); } - setPendingTagDocId(docId) { + setPendingTagDocId(docId: string | number | null): void { const normalized = docId != null ? String(docId) : null; if (this.pendingTagDocId === normalized) { return; @@ -568,7 +760,7 @@ export class WorkspaceEngine { this.emit(); } - setPendingRemovalTag(payload) { + setPendingRemovalTag(payload: unknown): void { if (payload === this.pendingRemovalTag) { return; } @@ -576,11 +768,11 @@ export class WorkspaceEngine { this.emit(); } - markLayoutDirty() { + markLayoutDirty(): void { this.layoutDirty = true; } - getLayout(docId) { + getLayout(docId: string | number | null): LayoutEntry | null { if (docId == null) { return null; } @@ -588,13 +780,20 @@ export class WorkspaceEngine { return this.layout.get(key) || null; } - updateLayoutEntry(docId, updater) { + updateLayoutEntry( + docId: string | number | null, + updater: + | LayoutEntry + | null + | undefined + | ((previous: LayoutEntry | null) => LayoutEntry | null | undefined), + ): void { if (docId == null) { return; } const key = String(docId); const previous = this.layout.get(key) || null; - const next = typeof updater === 'function' ? updater(previous || {}) : updater; + const next = typeof updater === 'function' ? updater(previous) : updater; if (!next) { this.layout.delete(key); } else { @@ -605,7 +804,7 @@ export class WorkspaceEngine { this.persistLayoutSnapshot(); } - bringToFront(docId) { + bringToFront(docId: string | number | null): void { if (docId == null) { return; } @@ -622,7 +821,16 @@ export class WorkspaceEngine { this.recalcVisibleDocIds(); } - applyTransform(docId, centerX, centerY, width, height, rotation, scale = 1, zIndex = null) { + applyTransform( + docId: string | number | null, + centerX: number, + centerY: number, + width: number, + height: number, + rotation: number, + scale = 1, + zIndex: number | null = null, + ): void { const key = docId != null ? String(docId) : null; if (!key) { return; @@ -639,7 +847,7 @@ export class WorkspaceEngine { }); } - finalizeGroupDrag(dragState) { + finalizeGroupDrag(dragState: DragState): void { if (!dragState?.groupItems) { return; } @@ -652,16 +860,18 @@ export class WorkspaceEngine { if (!key) { return; } - const entry = this.layout.get(key) || {}; - const centerX = item.currentCenterX ?? entry.centerX ?? dragState.originCenterX; - const centerY = item.currentCenterY ?? entry.centerY ?? dragState.originCenterY; - const rotation = item.displayRotation ?? entry.rotation ?? 0; + const entry = this.layout.get(key); + const centerX = item.currentCenterX ?? entry?.centerX ?? dragState.originCenterX ?? 0; + const centerY = item.currentCenterY ?? entry?.centerY ?? dragState.originCenterY ?? 0; + const rotation = item.displayRotation ?? entry?.rotation ?? 0; - const nextEntry = { - ...entry, + const nextEntry: LayoutEntry = { centerX, centerY, rotation, + z: entry?.z ?? this.zCounter, + width: entry?.width ?? item.width, + height: entry?.height ?? item.height, }; this.layout.set(key, nextEntry); @@ -683,7 +893,7 @@ export class WorkspaceEngine { this.persistLayoutSnapshot(); } - cancelInertiaAnimation(docId) { + cancelInertiaAnimation(docId: string | number | null): void { const key = docId != null ? String(docId) : null; if (!key) { return; @@ -695,7 +905,7 @@ export class WorkspaceEngine { this.inertiaAnimations.delete(key); } - disposeInertiaAnimations() { + disposeInertiaAnimations(): void { this.inertiaAnimations.forEach((animation) => { if (animation?.frameId != null) { window.cancelAnimationFrame(animation.frameId); @@ -704,7 +914,12 @@ export class WorkspaceEngine { this.inertiaAnimations.clear(); } - integrateRotation(simulationState, dt, torque = 0, dampingOverride = null) { + integrateRotation( + simulationState: InertiaSimulationState, + dt: number, + torque = 0, + dampingOverride: number | null = null, + ): boolean { const key = simulationState.docId != null ? String(simulationState.docId) : null; if (!key) { return true; @@ -761,7 +976,7 @@ export class WorkspaceEngine { return isSettled; } - startInertiaAnimation(docId, baseState) { + startInertiaAnimation(docId: string | number | null, baseState: InertiaSimulationState): void { const raf = window.requestAnimationFrame; if (!raf) { return; @@ -782,7 +997,7 @@ export class WorkspaceEngine { lastTimestamp: now, }; - const step = (timestamp) => { + const step = (timestamp: number) => { const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16; const previous = simulationState.lastTimestamp; let dt = (safeTimestamp - previous) / 1000; @@ -806,7 +1021,7 @@ export class WorkspaceEngine { this.inertiaAnimations.set(key, simulationState); } - syncLayoutSnapshot() { + syncLayoutSnapshot(): void { if (this.dragInProgress) { this.pendingSnapshotSync = true; return; @@ -816,7 +1031,7 @@ export class WorkspaceEngine { this.emit(); } - async persistLayoutSnapshot() { + async persistLayoutSnapshot(): Promise { if (this.dragInProgress) { this.pendingPersistSync = true; return; @@ -883,7 +1098,7 @@ export class WorkspaceEngine { }, 100); } - ensureLayoutForItems() { + ensureLayoutForItems(): void { const persistenceReady = !this.allowLayoutPersistence || !this.tenantId || !this.viewId || this.initialLoadDone; const canvasReady = Boolean(this.canvasSize.width && this.canvasSize.height); const sizesReady = !this.items.some((doc) => !this.ensureDocumentSize(doc)); @@ -907,11 +1122,11 @@ export class WorkspaceEngine { return; } - const next = new Map(); + const next = new Map(); let maxZ = this.zCounter; const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH; const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT; - const docsNeedingLayout = []; + const docsNeedingLayout: LayoutGenerationEntry[] = []; const currentEntries = new Map(this.layout); @@ -984,7 +1199,7 @@ export class WorkspaceEngine { this.recalcVisibleDocIds(); } - recalcVisibleDocIds() { + recalcVisibleDocIds(): void { const ensureSize = this.ensureDocumentSize; if (typeof ensureSize !== 'function') { return; @@ -1009,7 +1224,7 @@ export class WorkspaceEngine { { x: 0, y: canvasHeight }, ]; - const entries = []; + const entries: Array<{ key: string; z: number; polygon: Polygon }> = []; layoutMap.forEach((entry, docKey) => { if (!docKey) { return; @@ -1062,8 +1277,8 @@ export class WorkspaceEngine { entries.sort((a, b) => (b.z || 0) - (a.z || 0)); - const visiblePolygons = []; - const result = new Set(); + const visiblePolygons: Polygon[] = []; + const result = new Set(); entries.forEach(({ key, polygon }) => { if (polygon.length < 3) { @@ -1117,16 +1332,16 @@ export class WorkspaceEngine { this.emit(); } - subscribe(listener) { + subscribe(listener: WorkspaceSubscriber): () => void { this.subscribers.add(listener); return () => { this.subscribers.delete(listener); }; } - getSnapshot = () => this.snapshotCache; + getSnapshot = (): WorkspaceSnapshot => this.snapshotCache; - buildSnapshot() { + buildSnapshot(): WorkspaceSnapshot { return { layout: this.layoutSnapshot, canvasSize: this.canvasSize, @@ -1139,7 +1354,7 @@ export class WorkspaceEngine { }; } - emit() { + emit(): void { this.snapshotCache = this.buildSnapshot(); this.subscribers.forEach((listener) => { try { @@ -1150,7 +1365,7 @@ export class WorkspaceEngine { }); } - async loadPersistedLayout() { + async loadPersistedLayout(): Promise { if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) { return; } @@ -1198,7 +1413,10 @@ export class WorkspaceEngine { } } -export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => { +export const useWorkspaceSnapshot = ( + engine: WorkspaceEngine, + useSyncExternalStoreHook: UseSyncExternalStoreHook, +): WorkspaceSnapshot => { const useSyncExternalStore = useSyncExternalStoreHook; if (typeof useSyncExternalStore !== 'function') { throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook'); @@ -1211,7 +1429,9 @@ export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => { }; /* istanbul ignore next */ -const commonJsModule = globalThis?.module; +const commonJsModule = (globalThis as typeof globalThis & { + module?: { exports?: Record }; +}).module; if (commonJsModule?.exports) { commonJsModule.exports = { WorkspaceEngine, diff --git a/frontend/src/detail/PreviewZoomOverlay.jsx b/frontend/src/detail/PreviewZoomOverlay.tsx similarity index 74% rename from frontend/src/detail/PreviewZoomOverlay.jsx rename to frontend/src/detail/PreviewZoomOverlay.tsx index cd151c0..54dc09d 100644 --- a/frontend/src/detail/PreviewZoomOverlay.jsx +++ b/frontend/src/detail/PreviewZoomOverlay.tsx @@ -1,27 +1,45 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { CSSProperties, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; import { clamp } from '../utils/math'; +type PreviewNavigator = { + url: string; + alt?: string; + canGoPrev?: boolean; + canGoNext?: boolean; + goPrev?: () => void; + goNext?: () => void; +}; + +interface PreviewZoomOverlayProps { + open?: boolean; + display?: PreviewNavigator | null; + onClose?: () => void; +} + +type NaturalSize = { width: number | null; height: number | null }; +type FocusPoint = { xRatio: number; yRatio: number } | null; + const noop = () => {}; -const PreviewZoomOverlay = ({ +const PreviewZoomOverlay: React.FC = ({ open = false, display = null, onClose = noop, }) => { - const portalTarget = document.body; + const portalTarget = typeof document !== 'undefined' ? document.body : null; const [isNativeScale, setIsNativeScale] = useState(false); - const [naturalSize, setNaturalSize] = useState({ width: null, height: null }); + const [naturalSize, setNaturalSize] = useState({ width: null, height: null }); const [renderBackdrop, setRenderBackdrop] = useState(false); const [isBackdropVisible, setBackdropVisible] = useState(false); - const [displaySnapshot, setDisplaySnapshot] = useState(null); - const scrollRef = useRef(null); - const imageRef = useRef(null); - const focusRef = useRef(null); - const previouslyFocusedRef = useRef(null); - const visibilityTimerRef = useRef(null); - const displayTimerRef = useRef(null); + const [displaySnapshot, setDisplaySnapshot] = useState(null); + const scrollRef = useRef(null); + const imageRef = useRef(null); + const focusRef = useRef(null); + const previouslyFocusedRef = useRef(null); + const visibilityTimerRef = useRef(null); + const displayTimerRef = useRef(null); useEffect(() => { if (display?.url) { @@ -55,7 +73,7 @@ const PreviewZoomOverlay = ({ } setBackdropVisible(false); - visibilityTimerRef.current = setTimeout(() => { + visibilityTimerRef.current = window.setTimeout(() => { setRenderBackdrop(false); }, 260); @@ -117,6 +135,9 @@ const PreviewZoomOverlay = ({ }, [open, isNativeScale, naturalSize.width, naturalSize.height]); useEffect(() => { + if (typeof document === 'undefined') { + return; + } if (!open) { previouslyFocusedRef.current?.focus?.(); previouslyFocusedRef.current = null; @@ -124,7 +145,7 @@ const PreviewZoomOverlay = ({ } const active = document.activeElement; - previouslyFocusedRef.current = active?.focus ? active : null; + previouslyFocusedRef.current = active instanceof HTMLElement ? active : null; }, [open]); const activeDisplay = open && display?.url ? display : displaySnapshot; @@ -154,7 +175,7 @@ const PreviewZoomOverlay = ({ return () => cancelAnimationFrame(frame); }, [renderBackdrop, activeDisplay?.url]); - const handleKeyDown = (event) => { + const handleKeyDown = (event: React.KeyboardEvent) => { event.stopPropagation(); if (!open) { @@ -203,7 +224,7 @@ const PreviewZoomOverlay = ({ } }; - const toggleZoomAtPoint = (clientX, clientY) => { + const toggleZoomAtPoint = (clientX: number, clientY: number) => { const img = imageRef.current; setIsNativeScale((current) => { if (!current && img) { @@ -221,7 +242,7 @@ const PreviewZoomOverlay = ({ }); }; - const handleImageClick = (event) => { + const handleImageClick = (event: React.MouseEvent) => { event.stopPropagation(); toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0); }; @@ -232,11 +253,7 @@ const PreviewZoomOverlay = ({ const effectiveDisplay = activeDisplay; const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext); - const stageClassName = [ - 'preview-zoom__stage', - ] - .filter(Boolean) - .join(' '); + const stageClassName = 'preview-zoom__stage'; const containerClassName = [ 'preview-zoom__scroll', @@ -252,7 +269,7 @@ const PreviewZoomOverlay = ({ .filter(Boolean) .join(' '); - const imageStyle = isNativeScale + const imageStyle: CSSProperties = isNativeScale ? { cursor: 'zoom-out', width: naturalSize.width ? `${naturalSize.width}px` : 'auto', @@ -277,37 +294,34 @@ const PreviewZoomOverlay = ({ aria-label="Enlarged document preview" onClick={onClose} > -
-
event.stopPropagation()} - > - {effectiveDisplay.alt { - setNaturalSize({ - width: event.currentTarget.naturalWidth || null, - height: event.currentTarget.naturalHeight || null, - }); - }} - onClick={handleImageClick} - style={imageStyle} - /> -
- {navVisible ? ( -
- - ) : null} - - )} + title="Edit title" + > + + + ) : null} + + )} +
-
+ {titleError ?
{titleError}
: null}
@@ -776,7 +857,6 @@ const DocumentSummarySection = ({
{renderTags()} - {renderCorrespondents()} ); diff --git a/frontend/src/documents/DocumentThumbnailImage.jsx b/frontend/src/documents/DocumentThumbnailImage.tsx similarity index 66% rename from frontend/src/documents/DocumentThumbnailImage.jsx rename to frontend/src/documents/DocumentThumbnailImage.tsx index a1eef99..93db57b 100644 --- a/frontend/src/documents/DocumentThumbnailImage.jsx +++ b/frontend/src/documents/DocumentThumbnailImage.tsx @@ -1,11 +1,15 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { CSSProperties, JSX, MutableRefObject } from 'react'; import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; const DEFAULT_THUMBNAIL_SIZE = 48; // Detect when an element becomes visible within a scroll container so we can delay loading. -const useLazyVisibility = (rootRef, resetKey) => { - const targetRef = useRef(null); +const useLazyVisibility = ( + rootRef: MutableRefObject | null, + resetKey: string | number | null | undefined, +) => { + const targetRef = useRef(null); const [isVisible, setIsVisible] = useState(false); useEffect(() => { @@ -24,7 +28,7 @@ const useLazyVisibility = (rootRef, resetKey) => { return undefined; } - if (!('IntersectionObserver' in window)) { + if (typeof window === 'undefined' || !('IntersectionObserver' in window)) { setIsVisible(true); return undefined; } @@ -52,11 +56,54 @@ const useLazyVisibility = (rootRef, resetKey) => { return { ref: targetRef, isVisible }; }; -const getPageCount = (doc) => +const getPageCount = (doc: DocumentLike | null | undefined) => Number.isFinite(doc?.current_version?.metadata?.page_count) - ? doc.current_version.metadata.page_count + ? (doc?.current_version?.metadata?.page_count as number) : null; +type Identifier = string | number; + +interface DocumentVersionLike { + metadata?: { + page_count?: number; + width?: number; + height?: number; + [key: string]: unknown; + }; + assets?: unknown; + [key: string]: unknown; +} + +interface DocumentLike { + id?: Identifier; + current_version?: DocumentVersionLike; + [key: string]: unknown; +} + +interface AssetLike { + id?: Identifier; + url?: string | null; + metadata?: Record | null; + [key: string]: unknown; +} + +type EnsureAssetUrl = ( + documentId: Identifier, + asset: AssetLike, + options?: { start?: number; limit?: number; [key: string]: unknown }, +) => Promise | 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 | null; +} + const DocumentThumbnailImage = ({ document, ensureAssetUrl, @@ -64,12 +111,12 @@ const DocumentThumbnailImage = ({ alt = '', maxSize = DEFAULT_THUMBNAIL_SIZE, scrollRootRef = null, -}) => { +}: DocumentThumbnailImageProps): JSX.Element => { const documentId = document?.id; const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId); const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1)); - const thumbnailAsset = useMemo( + const thumbnailAsset = useMemo( () => getAssetFromVersion(document?.current_version, 'thumbnail'), [document?.current_version], ); @@ -89,7 +136,7 @@ const DocumentThumbnailImage = ({ }; }, [assetWidth, assetHeight, resolvedMaxSize]); - const innerStyle = useMemo( + const innerStyle = useMemo( () => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }), [dimensions.height, dimensions.width], ); @@ -98,10 +145,17 @@ const DocumentThumbnailImage = ({ if (!isVisible) { return null; } - return resolveDocumentAssetUrl(document, 'thumbnail', { - ensureAssetUrl, - getAsset: getDocumentAsset, - }); + const options: { + ensureAssetUrl?: EnsureAssetUrl; + getAsset?: GetDocumentAsset; + } = {}; + if (ensureAssetUrl) { + options.ensureAssetUrl = ensureAssetUrl; + } + if (getDocumentAsset) { + options.getAsset = getDocumentAsset; + } + return resolveDocumentAssetUrl(document, 'thumbnail', options); }, [document, ensureAssetUrl, getDocumentAsset, isVisible]); const pageCount = getPageCount(document); diff --git a/frontend/src/documents/DocumentsGrid.jsx b/frontend/src/documents/DocumentsGrid.tsx similarity index 84% rename from frontend/src/documents/DocumentsGrid.jsx rename to frontend/src/documents/DocumentsGrid.tsx index 4d84c95..c7f6af5 100644 --- a/frontend/src/documents/DocumentsGrid.jsx +++ b/frontend/src/documents/DocumentsGrid.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { DragEvent, MouseEvent, RefObject } from 'react'; import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons'; import DocumentThumbnailImage from './DocumentThumbnailImage'; import CorrespondentLinks from './CorrespondentLinks'; @@ -7,7 +8,85 @@ import { resolveCorrespondents } from './correspondents'; import { writeTagTransferData } from './tagTransfer'; import useInlineRename from './useInlineRename'; -const DocumentsGrid = ({ +export type Identifier = string | number; + +export interface FolderLike { + id?: Identifier | 'root'; + name?: string; +} + +export interface DocumentTag { + id?: Identifier; + label?: string; + color?: string | null; +} + +export interface DocumentCorrespondent { + id?: Identifier; + name?: string; + count?: number; +} + +export interface DocumentLike { + id?: Identifier; + title?: string; + tags?: DocumentTag[] | null; + correspondents?: DocumentCorrespondent[] | null; +} + +export type FolderEntry = { + type: 'folder'; + id: Identifier | 'root'; + key: string; + folder: FolderLike; +}; + +export type DocumentEntry = { + type: 'document'; + id: Identifier; + key: string; + document: DocumentLike; +}; + +export type DocumentsGridEntry = FolderEntry | DocumentEntry; + +type FolderEventHandler = (folder: FolderLike, event: MouseEvent) => void; +type DocumentEventHandler = (document: DocumentLike, event: MouseEvent) => void; + +interface DocumentsGridProps { + entries: DocumentsGridEntry[]; + selectedDocumentIdsSet?: Set | null; + selectedFolderIdsSet?: Set | null; + draggingDocumentIdsSet?: Set | null; + draggedFolderId?: Identifier | 'root' | null; + onFolderClick?: FolderEventHandler; + onFolderSelect?: (folderId: Identifier | 'root') => void; + onFolderDragOver?: (event: DragEvent, folderId: Identifier | 'root') => void; + onFolderDragLeave?: (event: DragEvent) => void; + onFolderDrop?: (event: DragEvent, folderId: Identifier | 'root') => void; + onFolderDragStart?: (event: DragEvent, folderId: Identifier | 'root') => void; + onFolderDragEnd?: (event: DragEvent) => void; + onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise | boolean; + onDocumentClick?: DocumentEventHandler; + onDocumentActivate?: DocumentEventHandler; + onDocumentDragStart?: (event: DragEvent, document: DocumentLike) => void; + onDocumentDragEnd?: (event: DragEvent) => void; + onDocumentTagDragOver?: (event: DragEvent) => void; + onDocumentTagDragLeave?: (event: DragEvent) => void; + onDocumentTagDrop?: (event: DragEvent, documentId: Identifier) => void; + ensureAssetUrl?: (...args: any[]) => unknown; + getDocumentAsset?: (...args: any[]) => unknown; + gridIconSize?: number; + tagLookupById?: Map | null; + onTagClick?: (tagId?: Identifier | null) => void; + scrollRef?: RefObject; + onCorrespondentClick?: (correspondentId?: Identifier | null) => void; + activeCorrespondentIdSet?: Set | null; + onClearSelection?: () => void; + onDocumentRename?: (docId: Identifier, title: string) => Promise | boolean; +} + +const DocumentsGrid: React.FC = ({ entries, selectedDocumentIdsSet, selectedFolderIdsSet, diff --git a/frontend/src/documents/DocumentsList.jsx b/frontend/src/documents/DocumentsList.tsx similarity index 84% rename from frontend/src/documents/DocumentsList.jsx rename to frontend/src/documents/DocumentsList.tsx index 714b2d8..10d3238 100644 --- a/frontend/src/documents/DocumentsList.jsx +++ b/frontend/src/documents/DocumentsList.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { DragEvent, MouseEvent, RefObject } from 'react'; import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons'; import { getTagColorStyle } from '../utils/colors'; import { formatDate } from '../utils/date'; @@ -8,7 +9,89 @@ import { resolveCorrespondents } from './correspondents'; import { writeTagTransferData } from './tagTransfer'; import useInlineRename from './useInlineRename'; -const DocumentsList = ({ +export type Identifier = string | number; + +export interface FolderLike { + id?: Identifier | 'root'; + name?: string; +} + +export interface DocumentTag { + id?: Identifier; + label?: string; + color?: string | null; +} + +export interface DocumentCorrespondent { + id?: Identifier; + name?: string; + count?: number; +} + +export interface DocumentLike { + id?: Identifier; + title?: string; + issued_at?: string | null; + created_at?: string | null; + uploaded_at?: string | null; + tags?: DocumentTag[] | null; + correspondents?: DocumentCorrespondent[] | null; +} + +export type FolderEntry = { + type: 'folder'; + id: Identifier | 'root'; + key: string; + folder: FolderLike; +}; + +export type DocumentEntry = { + type: 'document'; + id: Identifier; + key: string; + document: DocumentLike; +}; + +export type DocumentsListEntry = FolderEntry | DocumentEntry; + +export type FolderEventHandler = (folder: FolderLike, event: MouseEvent) => void; +export type DocumentEventHandler = (document: DocumentLike, event: MouseEvent) => void; + +export interface DocumentsListProps { + entries: DocumentsListEntry[]; + focusedRowKey?: string | null; + selectedDocumentIdsSet?: Set | null; + selectedFolderIdsSet?: Set | null; + draggingDocumentIdsSet?: Set | null; + draggedFolderId?: Identifier | 'root' | null; + ensureAssetUrl?: (...args: any[]) => unknown; + getDocumentAsset?: (...args: any[]) => unknown; + onFolderClick?: FolderEventHandler; + onFolderSelect?: (folderId: Identifier | 'root') => void; + onFolderDragOver?: (event: DragEvent, folderId: Identifier | 'root') => void; + onFolderDragLeave?: (event: DragEvent) => void; + onFolderDrop?: (event: DragEvent, folderId: Identifier | 'root') => void; + onFolderDragStart?: (event: DragEvent, folderId: Identifier | 'root') => void; + onFolderDragEnd?: (event: DragEvent) => void; + onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise | boolean; + onDocumentClick?: DocumentEventHandler; + onDocumentActivate?: DocumentEventHandler; + onDocumentDragStart?: (event: DragEvent, document: DocumentLike) => void; + onDocumentDragEnd?: (event: DragEvent) => void; + onDocumentTagDragOver?: (event: DragEvent) => void; + onDocumentTagDragLeave?: (event: DragEvent) => void; + onDocumentTagDrop?: (event: DragEvent, documentId: Identifier) => void; + onDocumentRename?: (documentId: Identifier, nextTitle: string) => Promise | boolean; + tagLookupById?: Map | null; + onTagClick?: (tagId?: Identifier | null) => void; + onCorrespondentClick?: (correspondentId?: Identifier | null) => void; + activeCorrespondentIdSet?: Set | null; + scrollRef?: RefObject; + onClearSelection?: () => void; +} + + +const DocumentsList: React.FC = ({ entries, focusedRowKey, selectedDocumentIdsSet, diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.ts similarity index 100% rename from frontend/src/documents/DocumentsPanel.jsx rename to frontend/src/documents/DocumentsPanel.ts diff --git a/frontend/src/documents/SelectionAssignmentMenu.jsx b/frontend/src/documents/SelectionAssignmentMenu.tsx similarity index 77% rename from frontend/src/documents/SelectionAssignmentMenu.jsx rename to frontend/src/documents/SelectionAssignmentMenu.tsx index 746731a..4942281 100644 --- a/frontend/src/documents/SelectionAssignmentMenu.jsx +++ b/frontend/src/documents/SelectionAssignmentMenu.tsx @@ -1,16 +1,57 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import useFloatingMenu from '../ui/useFloatingMenu'; import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../ui/icons'; -const STATE_ORDER = { +export type AssignmentState = 'all' | 'partial' | 'none'; + +export interface SelectionAssignmentMenuItem { + id?: string | number; + label?: string; + state?: AssignmentState; + count?: number | null; + total?: number | null; + color?: string | null; + value?: string | number; + payload?: unknown; +} + +export interface NormalizedSelectionAssignmentItem { + id: string | number; + label: string; + state: AssignmentState; + count: number | null; + total: number | null; + payload: unknown; +} + +export interface SelectionAssignmentMenuProps { + label: React.ReactNode; + items?: SelectionAssignmentMenuItem[]; + placeholder?: string; + emptyMessage?: string; + createLabel?: string | null; + onToggle?: (item: NormalizedSelectionAssignmentItem) => Promise | void; + onCreate?: (value: string) => Promise | 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 = { all: 0, partial: 1, none: 2, }; -const normalizeItems = (items) => +const normalizeItems = (items?: SelectionAssignmentMenuItem[]): NormalizedSelectionAssignmentItem[] => (Array.isArray(items) ? items : []) - .map((item) => { + .map((item) => { if (!item) { return null; } @@ -18,18 +59,23 @@ const normalizeItems = (items) => if (!trimmedLabel) { return null; } + const state: AssignmentState = item.state === 'all' + ? 'all' + : item.state === 'partial' + ? 'partial' + : 'none'; return { id: item.id ?? trimmedLabel, label: trimmedLabel, - state: item.state === 'all' ? 'all' : item.state === 'partial' ? 'partial' : 'none', + state, count: typeof item.count === 'number' ? item.count : null, total: typeof item.total === 'number' ? item.total : null, payload: item.payload ?? item, }; }) - .filter(Boolean); + .filter((item): item is NormalizedSelectionAssignmentItem => Boolean(item)); -const SelectionAssignmentMenu = ({ +const SelectionAssignmentMenu: React.FC = ({ label, items = [], placeholder = 'Search…', @@ -47,8 +93,8 @@ const SelectionAssignmentMenu = ({ renderItemLabel = null, positionStrategy = 'absolute', }) => { - const anchorRef = useRef(null); - const inputRef = useRef(null); + const anchorRef = useRef(null); + const inputRef = useRef(null); const [query, setQuery] = useState(''); const [pending, setPending] = useState(false); @@ -64,7 +110,14 @@ const SelectionAssignmentMenu = ({ align: 'center', positionStrategy, minWidth: 220, - }); + }) as { + isOpen: boolean; + toggle: () => void; + close: () => void; + menuRef: React.MutableRefObject; + menuStyle: CSSProperties | null; + updatePosition: () => void; + }; useEffect(() => { if (disabled && isOpen) { @@ -106,7 +159,7 @@ const SelectionAssignmentMenu = ({ }, [normalizedItems, query]); const handleToggle = useCallback( - async (item) => { + async (item: NormalizedSelectionAssignmentItem) => { if (!item || typeof onToggle !== 'function') { return; } @@ -124,7 +177,7 @@ const SelectionAssignmentMenu = ({ ); const handleCreate = useCallback( - async (event) => { + async (event: React.FormEvent) => { event?.preventDefault?.(); if (typeof onCreate !== 'function') { return; diff --git a/frontend/src/documents/SelectionFloatingActions.jsx b/frontend/src/documents/SelectionFloatingActions.tsx similarity index 62% rename from frontend/src/documents/SelectionFloatingActions.jsx rename to frontend/src/documents/SelectionFloatingActions.tsx index e326f97..9d97e5e 100644 --- a/frontend/src/documents/SelectionFloatingActions.jsx +++ b/frontend/src/documents/SelectionFloatingActions.tsx @@ -1,10 +1,4 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { TrashIcon, AnalyzeIcon, @@ -14,19 +8,90 @@ import { CorrespondentIcon, LoaderIcon, } from '../ui/icons'; -import SelectionAssignmentMenu from './SelectionAssignmentMenu'; +import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu'; import SelectionSummary from './SelectionSummary'; import { api, useAppState } from '../app/appState'; -const normalizeDocumentList = (selectedDocumentIds) => - Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : []; - const ROOT_FOLDER_LABEL = 'Documents'; -const buildFolderTreeOptions = (tree) => { - const entries = []; +type DocumentId = string | number; +type NullableDocumentId = DocumentId | null | undefined; - const traverse = (nodes, parentSegments) => { +type SelectedIdList = Array | 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 | null; + tags?: TagOption[] | null; + tagLookupById?: Map | null; + correspondents?: CorrespondentOption[] | null; + folderOptions?: SelectionAssignmentMenuItem[] | null; + onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise | void; + onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise | void; + onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise | void; + onBulkCorrespondentRemove?: (args: BulkCorrespondentRemoveArgs) => Promise | void; + onBulkReanalyze?: (documentIds: DocumentId[]) => Promise | void; + onDeleteSelection?: () => void; + onClearSelection?: () => void; + onMoveDocumentsToFolder?: (documentIds: DocumentId[], folderId: DocumentId) => Promise | void; +} + +const normalizeDocumentList = (selectedIds?: SelectedIdList): DocumentId[] => + Array.isArray(selectedIds) + ? selectedIds.filter((value): value is DocumentId => value !== null && value !== undefined) + : []; + +const buildFolderTreeOptions = (tree?: FolderTreeNode[] | null): SelectionAssignmentMenuItem[] => { + const entries: SelectionAssignmentMenuItem[] = []; + + const traverse = (nodes: FolderTreeNode[] | undefined | null, parentSegments: string[]) => { if (!Array.isArray(nodes) || nodes.length === 0) { return; } @@ -34,11 +99,21 @@ const buildFolderTreeOptions = (tree) => { if (!node || !node.id) { return; } - const trimmedName = node?.name?.trim?.(); + const trimmedName = node.name?.trim?.(); const name = trimmedName?.length ? trimmedName : 'Folder'; const nextSegments = parentSegments.concat([name]); const label = nextSegments.join('/'); - entries.push({ id: node.id, label }); + entries.push({ + id: node.id, + label, + state: 'none', + payload: { + id: node.id, + label, + segments: nextSegments, + depth: Math.max(nextSegments.length - 1, 0), + }, + }); if (Array.isArray(node.children) && node.children.length) { traverse(node.children, nextSegments); } @@ -47,19 +122,30 @@ const buildFolderTreeOptions = (tree) => { traverse(Array.isArray(tree) ? tree : [], [ROOT_FOLDER_LABEL]); - entries.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); + entries.sort((a, b) => (a.label || '').localeCompare(b.label || '', undefined, { sensitivity: 'base' })); - return [{ id: 'root', label: ROOT_FOLDER_LABEL }, ...entries]; + return [{ id: 'root', label: ROOT_FOLDER_LABEL, state: 'none', payload: { id: 'root' } }, ...entries]; }; -const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => { +const buildTagAssignments = ( + selectedDocuments: DocumentLike[], + tagLookupById: Map | null | undefined, + tags: TagOption[] | null | undefined, + total: number, +): SelectionAssignmentMenuItem[] => { if (!total) { return []; } - const map = new Map(); + const map = new Map(); - const ensureEntry = (id, label, color = null) => { + const ensureEntry = (id?: DocumentId, label?: string, color: string | null = null) => { const key = id ?? label; if (!key || !label) { return null; @@ -73,7 +159,7 @@ const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => { total, }); } - return map.get(key); + return map.get(key) ?? null; }; selectedDocuments.forEach((doc) => { @@ -106,14 +192,23 @@ const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => { }); }; -const buildCorrespondentAssignments = (selectedDocuments, correspondents, total) => { +const buildCorrespondentAssignments = ( + selectedDocuments: DocumentLike[], + correspondents: CorrespondentOption[] | null | undefined, + total: number, +): SelectionAssignmentMenuItem[] => { if (!total) { return []; } - const map = new Map(); + const map = new Map(); - const ensureEntry = (id, name) => { + const ensureEntry = (id?: DocumentId, name?: string) => { const key = id ?? name; if (!key || !name) { return null; @@ -126,7 +221,7 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total) total, }); } - return map.get(key); + return map.get(key) ?? null; }; selectedDocuments.forEach((doc) => { @@ -139,7 +234,7 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total) }); (correspondents || []).forEach((entry) => { - ensureEntry(entry?.id, entry?.name); + ensureEntry(entry?.id, entry?.name || entry?.label); }); return Array.from(map.values()).map((entry) => { @@ -156,14 +251,14 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total) }); }; -const SelectionFloatingActions = ({ +const SelectionFloatingActions: React.FC = ({ selectionCount = 0, - selectedDocumentIds, + selectedDocumentIds = [], selectedFolderIds = [], documentLookup, - tags, + tags = [], tagLookupById, - correspondents, + correspondents = [], folderOptions = [], onBulkTagAdd, onBulkTagRemove, @@ -174,12 +269,17 @@ const SelectionFloatingActions = ({ onClearSelection = null, onMoveDocumentsToFolder, }) => { - const { token, tenant } = useAppState(); + const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId } | null }; const tenantId = tenant?.id ?? null; - const [remoteFolderOptions, setRemoteFolderOptions] = useState(null); + const documentLookupMap = useMemo(() => ( + documentLookup instanceof Map ? documentLookup : new Map() + ), [documentLookup]); + const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null; + + const [remoteFolderOptions, setRemoteFolderOptions] = useState(null); const [loadingFolders, setLoadingFolders] = useState(false); - const folderTreeFetchRef = useRef(null); + const folderTreeFetchRef = useRef | null>(null); useEffect(() => { setRemoteFolderOptions(null); @@ -187,7 +287,7 @@ const SelectionFloatingActions = ({ setLoadingFolders(false); }, [tenantId, token]); - const requestFolderTree = useCallback(async () => { + const requestFolderTree = useCallback(async (): Promise => { if (!token) { setRemoteFolderOptions([]); return []; @@ -226,59 +326,55 @@ const SelectionFloatingActions = ({ requestFolderTree(); }, [requestFolderTree]); - const effectiveFolderOptions = useMemo(() => { + const effectiveFolderOptions = useMemo(() => { if (remoteFolderOptions !== null) { return remoteFolderOptions; } return Array.isArray(folderOptions) ? folderOptions : []; }, [remoteFolderOptions, folderOptions]); - const documentIdList = useMemo( + const documentIdList = useMemo( () => normalizeDocumentList(selectedDocumentIds), [selectedDocumentIds], ); - const folderIdList = useMemo( + const folderIdList = useMemo( () => normalizeDocumentList(selectedFolderIds), [selectedFolderIds], ); const documentCount = documentIdList.length; const folderCount = folderIdList.length; - const totalCount = typeof selectionCount === 'number' - ? selectionCount - : documentCount + folderCount; + const totalCount = typeof selectionCount === 'number' ? selectionCount : documentCount + folderCount; - const selectedDocuments = useMemo(() => { - if (!documentIdList.length || !(documentLookup instanceof Map)) { + const selectedDocuments = useMemo(() => { + if (!documentIdList.length || !(documentLookupMap instanceof Map)) { return []; } return documentIdList - .map((id) => documentLookup.get(id)) - .filter(Boolean); - }, [documentIdList, documentLookup]); + .map((id) => documentLookupMap.get(id)) + .filter((doc): doc is DocumentLike => Boolean(doc)); + }, [documentIdList, documentLookupMap]); const selectedDocCount = selectedDocuments.length; - const moveAssignments = useMemo(() => { + const moveAssignments = useMemo(() => { if (!Array.isArray(effectiveFolderOptions)) { return []; } return effectiveFolderOptions - .map((option) => { - const id = option?.id ?? option?.value ?? option; + .map((option) => { + const id = (option?.id ?? option?.payload?.id ?? option?.value) as DocumentId | undefined; if (!id) { return null; } - const label = option?.label || option?.name || String(id); - const segments = label.split('/'); + const label = option?.label || option?.payload?.label || option?.name || String(id); + const segments = typeof label === 'string' ? label.split('/') : [label]; const depth = Math.max(segments.length - 1, 0); return { id, label, state: 'none', - count: null, - total: null, payload: { id, label, @@ -287,12 +383,12 @@ const SelectionFloatingActions = ({ }, }; }) - .filter(Boolean); + .filter((entry): entry is SelectionAssignmentMenuItem => Boolean(entry)); }, [effectiveFolderOptions]); const tagAssignments = useMemo( - () => buildTagAssignments(selectedDocuments, tagLookupById, tags, selectedDocCount), - [selectedDocuments, tagLookupById, tags, selectedDocCount], + () => buildTagAssignments(selectedDocuments, tagLookupMap, tags, selectedDocCount), + [selectedDocuments, tagLookupMap, tags, selectedDocCount], ); const correspondentAssignments = useMemo( @@ -300,9 +396,10 @@ const SelectionFloatingActions = ({ [selectedDocuments, correspondents, selectedDocCount], ); - const renderFolderLabel = useCallback((item) => { - const segments = item?.payload?.segments || (item?.label ? item.label.split('/') : []); - const depth = item?.payload?.depth ?? Math.max(segments.length - 1, 0); + const renderFolderLabel = useCallback((item: SelectionAssignmentMenuItem) => { + const payload = (item?.payload as { segments?: string[]; depth?: number }) || {}; + const segments = payload.segments || (typeof item.label === 'string' ? item.label.split('/') : []); + const depth = payload.depth ?? Math.max(segments.length - 1, 0); const clampedDepth = Math.min(depth, 6); const indentWidth = clampedDepth > 0 ? clampedDepth * 0.9 : 0; const name = segments.length ? segments[segments.length - 1] : item?.label || 'Folder'; @@ -328,21 +425,21 @@ const SelectionFloatingActions = ({ }, []); const handleToggleTagAssignment = useCallback( - async (item) => { + async (item: SelectionAssignmentMenuItem) => { if (!selectedDocCount || !item) { return; } if (item.state === 'all') { - await onBulkTagRemove?.({ label: item.label, input: null, documentIds: documentIdList }); + await onBulkTagRemove?.({ label: item.label || '', input: null, documentIds: documentIdList }); } else { - await onBulkTagAdd?.({ label: item.label, input: null, documentIds: documentIdList }); + await onBulkTagAdd?.({ label: item.label || '', input: null, documentIds: documentIdList }); } }, [selectedDocCount, onBulkTagAdd, onBulkTagRemove, documentIdList], ); const handleCreateTagAssignment = useCallback( - async (label) => { + async (label: string) => { if (!selectedDocCount || !label) { return; } @@ -352,7 +449,7 @@ const SelectionFloatingActions = ({ ); const handleToggleCorrespondentAssignment = useCallback( - async (item) => { + async (item: SelectionAssignmentMenuItem) => { if (!selectedDocCount || !item) { return; } @@ -365,14 +462,14 @@ const SelectionFloatingActions = ({ documentIds: documentIdList, }); } else { - await onBulkCorrespondentAdd?.({ name: item.label, input: null, documentIds: documentIdList }); + await onBulkCorrespondentAdd?.({ name: item.label || '', input: null, documentIds: documentIdList }); } }, [selectedDocCount, onBulkCorrespondentAdd, onBulkCorrespondentRemove, documentIdList], ); const handleCreateCorrespondentAssignment = useCallback( - async (name) => { + async (name: string) => { if (!selectedDocCount || !name) { return; } @@ -382,15 +479,18 @@ const SelectionFloatingActions = ({ ); const handleMoveSelectionToFolder = useCallback( - async (option) => { + async (option: unknown) => { if (!documentIdList.length || typeof onMoveDocumentsToFolder !== 'function') { return; } - const value = option?.id ?? option?.value ?? option; - if (!value) { + const candidate = option as { id?: DocumentId; value?: DocumentId } | DocumentId | null | undefined; + const value = typeof candidate === 'object' + ? candidate?.id ?? candidate?.value ?? null + : candidate; + if (!value && value !== 0) { return; } - await onMoveDocumentsToFolder(documentIdList, value); + await onMoveDocumentsToFolder(documentIdList, value as DocumentId); }, [documentIdList, onMoveDocumentsToFolder], ); diff --git a/frontend/src/documents/SelectionSummary.jsx b/frontend/src/documents/SelectionSummary.tsx similarity index 86% rename from frontend/src/documents/SelectionSummary.jsx rename to frontend/src/documents/SelectionSummary.tsx index 6505bbc..879680b 100644 --- a/frontend/src/documents/SelectionSummary.jsx +++ b/frontend/src/documents/SelectionSummary.tsx @@ -1,7 +1,13 @@ import React from 'react'; import { FileIcon, FolderOutlineIcon } from '../ui/icons'; -const SelectionSummary = ({ documentCount = 0, folderCount = 0, totalCount = 0 }) => { +interface SelectionSummaryProps { + documentCount?: number; + folderCount?: number; + totalCount?: number; +} + +const SelectionSummary: React.FC = ({ documentCount = 0, folderCount = 0, totalCount = 0 }) => { const docCount = Number(documentCount) || 0; const folderCountNumber = Number(folderCount) || 0; const aggregateCount = docCount + folderCountNumber; diff --git a/frontend/src/documents/correspondents.js b/frontend/src/documents/correspondents.js deleted file mode 100644 index a2e990e..0000000 --- a/frontend/src/documents/correspondents.js +++ /dev/null @@ -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; diff --git a/frontend/src/documents/correspondents.ts b/frontend/src/documents/correspondents.ts new file mode 100644 index 0000000..96f9fbc --- /dev/null +++ b/frontend/src/documents/correspondents.ts @@ -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(); + 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; diff --git a/frontend/src/documents/documentActions.js b/frontend/src/documents/documentActions.ts similarity index 50% rename from frontend/src/documents/documentActions.js rename to frontend/src/documents/documentActions.ts index 7852a94..3cb47ea 100644 --- a/frontend/src/documents/documentActions.js +++ b/frontend/src/documents/documentActions.ts @@ -1,9 +1,23 @@ import { openOcrTextInNewTab } from '../utils/ocr'; +type ResolveApiPath = (path: string) => string; +type EnsurePreviewData = (id: string | number) => Promise; +type EnsureAssetUrl = (id: string | number, asset: unknown, options?: unknown) => Promise; +type GetDocumentAsset = (document: DocumentLike, type: string) => unknown; + +interface DocumentVersion { + download_path?: string | null; +} + +export interface DocumentLike { + id?: string | number; + current_version?: DocumentVersion | null; +} + const asyncFalse = async () => false; -const resolveDocumentDownloadHref = (document, resolveApiPath) => { - if (!document || typeof resolveApiPath !== 'function') { +const resolveDocumentDownloadHref = (document: DocumentLike | null | undefined, resolveApiPath?: ResolveApiPath | null): string | null => { + if (!document || !resolveApiPath) { return null; } const downloadPath = document.current_version?.download_path; @@ -13,13 +27,23 @@ const resolveDocumentDownloadHref = (document, resolveApiPath) => { return resolveApiPath(downloadPath); }; -const hasDocumentOcrAsset = (document, getDocumentAsset) => { - if (!document || typeof getDocumentAsset !== 'function') { +const hasDocumentOcrAsset = (document: DocumentLike | null | undefined, getDocumentAsset?: GetDocumentAsset | null): boolean => { + if (!document || !getDocumentAsset) { return false; } return Boolean(getDocumentAsset(document, 'ocr-text')); }; +interface CreateDocumentActionStateArgs { + document: DocumentLike | null; + resolveApiPath?: ResolveApiPath | null; + ensurePreviewData: EnsurePreviewData; + ensureAssetUrl: EnsureAssetUrl; + getDocumentAsset?: GetDocumentAsset | null; + notifyApiError?: (error: unknown, message: string) => void; + ocrErrorMessage?: string; +} + export const createDocumentActionState = ({ document, resolveApiPath, @@ -28,7 +52,7 @@ export const createDocumentActionState = ({ getDocumentAsset, notifyApiError, ocrErrorMessage = 'Unable to open OCR text.', -}) => { +}: CreateDocumentActionStateArgs) => { if (!document) { return { downloadHref: null, @@ -49,14 +73,12 @@ export const createDocumentActionState = ({ getDocumentAsset, ensureAssetUrl, }); - if (!success && typeof notifyApiError === 'function') { - notifyApiError(new Error('OCR text URL unavailable.'), ocrErrorMessage); + if (!success) { + notifyApiError?.(new Error('OCR text URL unavailable.'), ocrErrorMessage); } return success; } catch (error) { - if (typeof notifyApiError === 'function') { - notifyApiError(error, ocrErrorMessage); - } + notifyApiError?.(error, ocrErrorMessage); throw error; } } diff --git a/frontend/src/documents/documentMetadata.js b/frontend/src/documents/documentMetadata.ts similarity index 52% rename from frontend/src/documents/documentMetadata.js rename to frontend/src/documents/documentMetadata.ts index ad6f8f6..c11b3a7 100644 --- a/frontend/src/documents/documentMetadata.js +++ b/frontend/src/documents/documentMetadata.ts @@ -1,6 +1,29 @@ import { formatDateTime } from '../utils/date'; -export const buildDocumentMetadataItems = (document) => { +interface DocumentVersionMetadata { + checksum?: string | null; +} + +interface DocumentMetadata { + [key: string]: unknown; +} + +export interface DocumentLike { + created_at?: string | null; + updated_at?: string | null; + filename?: string | null; + original_name?: string | null; + content_type?: string | null; + metadata?: DocumentMetadata | null; + current_version?: DocumentVersionMetadata | null; +} + +export interface DocumentMetadataItem { + label: string; + value: string | null | undefined; +} + +export const buildDocumentMetadataItems = (document?: DocumentLike | null): DocumentMetadataItem[] => { if (!document) { return []; } @@ -29,8 +52,8 @@ export const buildDocumentMetadataItems = (document) => { ]; }; -export const extractDocumentMetadataPayload = (document) => { - if (!document || !document.metadata) { +export const extractDocumentMetadataPayload = (document?: DocumentLike | null): DocumentMetadata | null => { + if (!document?.metadata) { return null; } const keys = Object.keys(document.metadata); diff --git a/frontend/src/documents/documentSummary.js b/frontend/src/documents/documentSummary.ts similarity index 58% rename from frontend/src/documents/documentSummary.js rename to frontend/src/documents/documentSummary.ts index 7243a88..6829d35 100644 --- a/frontend/src/documents/documentSummary.js +++ b/frontend/src/documents/documentSummary.ts @@ -1,13 +1,71 @@ import { formatFileSize } from '../utils/format'; import { formatDateTime as defaultFormatDateTime } from '../utils/date'; -const coercePageCount = (metadata) => { +interface DocumentMetadata { + page_count?: number | string | null; +} + +interface DocumentVersion { + size_bytes?: number | string | null; + metadata?: DocumentMetadata | null; +} + +interface TagEntry { + label?: string | null; +} + +interface CorrespondentEntry { + name?: string | null; +} + +export interface SummaryDocument { + title?: string | null; + original_name?: string | null; + content_type?: string | null; + current_version?: DocumentVersion | null; + created_at?: string | null; + updated_at?: string | null; + issued_at?: string | null; + folder_path?: string | null; + tags?: TagEntry[] | null; + correspondents?: CorrespondentEntry[] | null; +} + +interface DescribeSummaryOptions { + formatDateTime?: typeof defaultFormatDateTime; +} + +export interface DocumentSummaryRow { + key: string; + label: string; + value: string | null | undefined; +} + +export interface DocumentSummary { + title: string | undefined; + originalName: string | null | undefined; + mimeTypeLabel: string; + sizeLabel: string; + createdAtLabel: string; + issuedLabel: string; + updatedAtLabel: string; + pageCount: number | null; + pageCountLabel: string; + folderLabel: string | null | undefined; + tags: TagEntry[]; + correspondents: CorrespondentEntry[]; + tagsSummary: string; + correspondentsSummary: string; + summaryRows: DocumentSummaryRow[]; +} + +const coercePageCount = (metadata: DocumentMetadata | null | undefined): number | null => { const raw = metadata?.page_count; if (typeof raw === 'number') { return Number.isFinite(raw) && raw >= 0 ? raw : null; } if (raw != null && raw !== '') { - const parsed = Number.parseInt(raw, 10); + const parsed = Number.parseInt(String(raw), 10); if (Number.isFinite(parsed) && parsed >= 0) { return parsed; } @@ -15,21 +73,14 @@ const coercePageCount = (metadata) => { return null; }; -const sanitizeTags = (tags) => { - if (!Array.isArray(tags)) { - return []; - } - return tags.filter(Boolean); -}; +const sanitizeArray = (entries: (T | null | undefined)[] | null | undefined): T[] => + Array.isArray(entries) ? entries.filter(Boolean) as T[] : []; -const sanitizeCorrespondents = (entries) => { - if (!Array.isArray(entries)) { - return []; - } - return entries.filter(Boolean); -}; +export const describeDocumentSummary = (document?: SummaryDocument | null, options: DescribeSummaryOptions = {}): DocumentSummary => { + const { + formatDateTime = defaultFormatDateTime, + } = options; -export const describeDocumentSummary = (document, options = {}) => { if (!document) { return { title: '', @@ -50,10 +101,6 @@ export const describeDocumentSummary = (document, options = {}) => { }; } - const { - formatDateTime = defaultFormatDateTime, - } = options; - const originalName = document.original_name; const mimeTypeLabel = document.content_type || 'Unknown'; @@ -70,18 +117,18 @@ export const describeDocumentSummary = (document, options = {}) => { const folderLabel = document.folder_path; - const tags = sanitizeTags(document.tags); - const correspondents = sanitizeCorrespondents(document.correspondents); + const tags = sanitizeArray(document.tags); + const correspondents = sanitizeArray(document.correspondents); - const tagLabels = tags.map((tag) => tag.label).filter(Boolean); - const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean); + const tagLabels = tags.map((tag) => tag.label).filter(Boolean) as string[]; + const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean) as string[]; const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—'; const correspondentsSummary = correspondentLabels.length ? correspondentLabels.join(', ') : '—'; - const summaryRows = [ + const summaryRows: DocumentSummaryRow[] = [ { key: 'created', label: 'Created', value: createdAtLabel }, { key: 'size', label: 'Size', value: sizeLabel }, { key: 'type', label: 'Type', value: mimeTypeLabel }, diff --git a/frontend/src/documents/hooks/useBulkDocumentActions.js b/frontend/src/documents/hooks/useBulkDocumentActions.ts similarity index 75% rename from frontend/src/documents/hooks/useBulkDocumentActions.js rename to frontend/src/documents/hooks/useBulkDocumentActions.ts index 798163c..bff4247 100644 --- a/frontend/src/documents/hooks/useBulkDocumentActions.js +++ b/frontend/src/documents/hooks/useBulkDocumentActions.ts @@ -1,5 +1,36 @@ import { useCallback } from 'react'; +export type Identifier = string | number; + +type ApiClient = { + post: (url: string, payload: unknown) => Promise<{ data: T } | T>; + delete: (url: string) => Promise; +}; + +type BulkAssignmentResponse = { + assigned?: number; + removed?: number; +}; + +type CorrespondentAssignment = { + correspondent_id?: Identifier; +}; + +interface UseBulkDocumentActionsArgs { + api: ApiClient; + resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[]; + correspondentLookupByName: Map; + handleCorrespondentCreate: (payload: { name: string }) => Promise<{ id?: Identifier } | null>; + refreshCurrentFolder: () => Promise | void; + setStatusMessage: (message: string, variant?: string) => void; + selectedDocumentIds?: Identifier[]; + selectedFolderIds?: Identifier[]; + handleDocumentsDelete: (ids: Identifier[], options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise; + handleFolderDelete: (id: Identifier, options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise; + clearDocumentSelection: () => void; + setLoading: (value: boolean) => void; +} + const useBulkDocumentActions = ({ api, resolveTargetDocumentIds, @@ -13,9 +44,9 @@ const useBulkDocumentActions = ({ handleFolderDelete, clearDocumentSelection, setLoading, -}) => { +}: UseBulkDocumentActionsArgs) => { const handleBulkCorrespondentAdd = useCallback( - async ({ name, input, documentIds }) => { + async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => { const trimmed = name?.trim?.() || ''; if (!trimmed) { setStatusMessage('Correspondent name is required.', 'error'); @@ -41,7 +72,7 @@ const useBulkDocumentActions = ({ return; } - const response = await api.post('/documents/bulk/correspondents', { + const response = await api.post('/documents/bulk/correspondents', { document_ids: targets, assignments: [ { @@ -51,7 +82,7 @@ const useBulkDocumentActions = ({ action: 'add', }); - const { assigned = 0, removed = 0 } = response.data || {}; + const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response; await refreshCurrentFolder(); const assignedSuffix = assigned === 1 ? '' : 's'; @@ -83,7 +114,7 @@ const useBulkDocumentActions = ({ ); const handleBulkCorrespondentRemove = useCallback( - async ({ assignments = [], documentIds }) => { + async ({ assignments = [], documentIds }: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => { if (!assignments.length) { setStatusMessage('Select a correspondent to remove.', 'error'); return; @@ -100,13 +131,13 @@ const useBulkDocumentActions = ({ correspondent_id: entry.correspondent_id, })); - const response = await api.post('/documents/bulk/correspondents', { + const response = await api.post('/documents/bulk/correspondents', { document_ids: targets, assignments: normalizedAssignments, action: 'remove', }); - const { assigned = 0, removed = 0 } = response.data || {}; + const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response; await refreshCurrentFolder(); if (removed > 0) { diff --git a/frontend/src/documents/hooks/useDocumentsPanelProps.js b/frontend/src/documents/hooks/useDocumentsPanelProps.js deleted file mode 100644 index 80c7ccc..0000000 --- a/frontend/src/documents/hooks/useDocumentsPanelProps.js +++ /dev/null @@ -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; diff --git a/frontend/src/documents/hooks/useDocumentsPanelProps.ts b/frontend/src/documents/hooks/useDocumentsPanelProps.ts new file mode 100644 index 0000000..287aaeb --- /dev/null +++ b/frontend/src/documents/hooks/useDocumentsPanelProps.ts @@ -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; + 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; + +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; diff --git a/frontend/src/documents/hooks/useDocumentsSelection.js b/frontend/src/documents/hooks/useDocumentsSelection.ts similarity index 68% rename from frontend/src/documents/hooks/useDocumentsSelection.js rename to frontend/src/documents/hooks/useDocumentsSelection.ts index c0c0d86..8b848bd 100644 --- a/frontend/src/documents/hooks/useDocumentsSelection.js +++ b/frontend/src/documents/hooks/useDocumentsSelection.ts @@ -1,5 +1,41 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; +interface FolderEntry { + id: string | number; + [key: string]: unknown; +} + +interface DocumentEntry { + id: string | number; + [key: string]: unknown; +} + +interface NavigableRow { + key: string; + type: 'folder' | 'document'; + id: string | number; +} + +interface UseDocumentsSelectionOptions { + showingSearchResults: boolean; + currentSubfolders: FolderEntry[]; + visibleDocuments: DocumentEntry[]; + resolveFolderRowKey: (id: string | number) => string | null | undefined; + resolveDocumentRowKey: (id: string | number) => string | null | undefined; + configureSelectionEnvironment: (config: { visibleRowKeySet: Set; navigableRowKeys: string[] }) => void; + visibleRowKeySet: Set; + selectedEntries: string[]; + selectionAnchorRef: { current: string | null }; + promoteSelectionOrderRaw: (id: string | number) => void; + setFocusedDocumentId: (id: string | number | null) => void; + setActivePreviewId: (id: string | number | null) => void; + clearSelection: () => void; + focusedDocumentId: string | number | null; + setFocusedRowKey: (value: string | null | ((current: string | null) => string | null)) => void; + focusedRowKey: string | null; + isFolderRowKey: (key: string | null) => boolean; +} + const useDocumentsSelection = ({ showingSearchResults, currentSubfolders, @@ -18,9 +54,9 @@ const useDocumentsSelection = ({ setFocusedRowKey, focusedRowKey, isFolderRowKey, -}) => { - const navigableRows = useMemo(() => { - const entries = []; +}: UseDocumentsSelectionOptions) => { + const navigableRows = useMemo(() => { + const entries: NavigableRow[] = []; if (!showingSearchResults) { currentSubfolders.forEach((folder) => { const key = resolveFolderRowKey(folder.id); @@ -51,7 +87,7 @@ const useDocumentsSelection = ({ }, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]); const promoteSelectionOrder = useCallback( - (docId) => { + (docId: string | number | null) => { if (!docId) return; promoteSelectionOrderRaw(docId); const rowKey = resolveDocumentRowKey(docId); @@ -68,7 +104,7 @@ const useDocumentsSelection = ({ clearSelection(); }, [clearSelection]); - const prevFocusedDocIdRef = useRef(focusedDocumentId); + const prevFocusedDocIdRef = useRef(focusedDocumentId); useEffect(() => { const previous = prevFocusedDocIdRef.current; if (previous === focusedDocumentId) { diff --git a/frontend/src/documents/panel/DocumentsPanel.jsx b/frontend/src/documents/panel/DocumentsPanel.tsx similarity index 99% rename from frontend/src/documents/panel/DocumentsPanel.jsx rename to frontend/src/documents/panel/DocumentsPanel.tsx index cff1161..e34ca5e 100644 --- a/frontend/src/documents/panel/DocumentsPanel.jsx +++ b/frontend/src/documents/panel/DocumentsPanel.tsx @@ -15,7 +15,11 @@ const EntryType = { document: 'document', }; -const DocumentsPanel = ({ +interface DocumentsPanelProps { + [key: string]: any; +} + +const DocumentsPanel: React.FC = ({ currentFolderName, breadcrumbs, onRefresh, diff --git a/frontend/src/documents/panel/DocumentsToolbar.jsx b/frontend/src/documents/panel/DocumentsToolbar.tsx similarity index 86% rename from frontend/src/documents/panel/DocumentsToolbar.jsx rename to frontend/src/documents/panel/DocumentsToolbar.tsx index 083a314..d01b713 100644 --- a/frontend/src/documents/panel/DocumentsToolbar.jsx +++ b/frontend/src/documents/panel/DocumentsToolbar.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import type { JSX } from 'react'; import { ViewListIcon, ViewGridIcon, @@ -12,18 +12,34 @@ import { } from '../../ui/icons'; import SortFieldQuickMenu from './SortFieldQuickMenu'; +type ViewMode = 'list' | 'grid' | 'desk' | (string & {}); +type SortDirection = 'asc' | 'desc' | (string & {}); + +interface DocumentsTableHeaderActionOptions { + viewMode?: ViewMode; + onViewModeChange?: (mode: ViewMode) => void; + onRefresh: () => void; + sortField?: string; + onSortFieldChange?: (field: string) => void; + sortDirection?: SortDirection; + onSortDirectionToggle?: () => void; + isFilterActive?: boolean; + includeDescendants?: boolean; + onToggleIncludeDescendants?: () => void; +} + export const createDocumentsTableHeaderActions = ({ - viewMode, + viewMode = 'list', onViewModeChange, onRefresh, sortField = 'title', - onSortFieldChange = null, + onSortFieldChange, sortDirection = 'asc', - onSortDirectionToggle = null, + onSortDirectionToggle, isFilterActive = false, includeDescendants = true, - onToggleIncludeDescendants = null, -}) => { + onToggleIncludeDescendants, +}: DocumentsTableHeaderActionOptions): JSX.Element => { const isListView = viewMode === 'list'; const isGridView = viewMode === 'grid'; const isDeskView = viewMode === 'desk'; diff --git a/frontend/src/documents/panel/SortFieldQuickMenu.jsx b/frontend/src/documents/panel/SortFieldQuickMenu.tsx similarity index 85% rename from frontend/src/documents/panel/SortFieldQuickMenu.jsx rename to frontend/src/documents/panel/SortFieldQuickMenu.tsx index 35d5631..6129baf 100644 --- a/frontend/src/documents/panel/SortFieldQuickMenu.jsx +++ b/frontend/src/documents/panel/SortFieldQuickMenu.tsx @@ -14,7 +14,12 @@ const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((acc, option) => { return next; }, {}); -const SortFieldQuickMenu = ({ sortField, onChange }) => { +interface SortFieldQuickMenuProps { + sortField: string; + onChange?: (value: string) => void; +} + +const SortFieldQuickMenu: React.FC = ({ sortField, onChange }) => { const currentOption = useMemo( () => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0], [sortField], @@ -26,8 +31,8 @@ const SortFieldQuickMenu = ({ sortField, onChange }) => { ); const handleSelect = useCallback( - (value, option) => { - if (typeof onChange !== 'function') { + (value: string, option?: { id?: string; original?: { id?: string } }) => { + if (!onChange) { return; } const nextValue = option?.id || option?.original?.id || value; diff --git a/frontend/src/documents/panel/createDocumentsSurface.js b/frontend/src/documents/panel/createDocumentsSurface.tsx similarity index 89% rename from frontend/src/documents/panel/createDocumentsSurface.js rename to frontend/src/documents/panel/createDocumentsSurface.tsx index a0ee776..5912565 100644 --- a/frontend/src/documents/panel/createDocumentsSurface.js +++ b/frontend/src/documents/panel/createDocumentsSurface.tsx @@ -1,10 +1,26 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import DocumentViewerPanel from '../../preview/DocumentViewerPanel'; import SelectionFloatingActions from '../SelectionFloatingActions'; import createWorkspaceSurfaceConfig from '../workspaceHeader'; import DocumentsPanel from './DocumentsPanel'; import { createDocumentsTableHeaderActions } from './DocumentsToolbar'; +interface Breadcrumb { + id?: string | number; + name?: string; + label?: string; + title?: string; +} + +interface CreateDocumentsSurfaceArgs { + tableProps: Record; + parentBreadcrumb?: Breadcrumb | null; + onNavigateParent?: () => void; + renderSidebarToggle?: () => ReactNode; + detailProps?: Record | null; + detailOpen?: boolean; +} + const createDocumentsSurface = ({ tableProps, parentBreadcrumb, @@ -12,7 +28,7 @@ const createDocumentsSurface = ({ renderSidebarToggle, detailProps, detailOpen = false, -}) => { +}: CreateDocumentsSurfaceArgs) => { const { currentFolderName, breadcrumbs, diff --git a/frontend/src/documents/tagTransfer.js b/frontend/src/documents/tagTransfer.ts similarity index 60% rename from frontend/src/documents/tagTransfer.js rename to frontend/src/documents/tagTransfer.ts index 5679c89..ffa31f3 100644 --- a/frontend/src/documents/tagTransfer.js +++ b/frontend/src/documents/tagTransfer.ts @@ -1,7 +1,18 @@ const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; const TAG_TEXT_MIME_TYPE = 'text/plain'; -const serializePayload = (payload) => { +interface TagPayload { + id: string | number; + label: string; + sourceDocId: string | number | null; +} + +interface TagLike { + id?: string | number; + label?: string | null; +} + +const serializePayload = (payload: TagPayload): string | null => { try { return JSON.stringify(payload); } catch (error) { @@ -10,8 +21,8 @@ const serializePayload = (payload) => { } }; -export const createTagTransferPayload = (tag, sourceDocId = null) => { - if (!tag || !tag.id) { +export const createTagTransferPayload = (tag: TagLike | null | undefined, sourceDocId: string | number | null = null): TagPayload | null => { + if (!tag || tag.id == null) { return null; } @@ -22,7 +33,7 @@ export const createTagTransferPayload = (tag, sourceDocId = null) => { }; }; -export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => { +export const writeTagTransferData = (dataTransfer: DataTransfer | null, tag: TagLike, sourceDocId: string | number | null = null): void => { if (!dataTransfer) { return; } @@ -38,8 +49,7 @@ export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => { } try { - dataTransfer.setData(TAG_MIME_TYPES[0], serialized); - dataTransfer.setData(TAG_MIME_TYPES[1], serialized); + TAG_MIME_TYPES.forEach((type) => dataTransfer.setData(type, serialized)); if (payload.label) { dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label); } @@ -48,7 +58,7 @@ export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => { } }; -export const readTagTransferData = (dataTransfer) => { +export const readTagTransferData = (dataTransfer: DataTransfer | null | undefined): string | null => { if (!dataTransfer) { return null; } @@ -67,15 +77,17 @@ export const readTagTransferData = (dataTransfer) => { return null; }; -export const parseTagTransferPayload = (input) => { +type DragEventLike = DragEvent | { dataTransfer?: DataTransfer | null }; + +export const parseTagTransferPayload = (input: DataTransfer | DragEventLike | null): TagPayload | null => { const dataTransfer = input && 'dataTransfer' in input ? input.dataTransfer : input; - const raw = readTagTransferData(dataTransfer); + const raw = readTagTransferData(dataTransfer || null); if (!raw) { return null; } try { - return JSON.parse(raw); + return JSON.parse(raw) as TagPayload; } catch (error) { console.warn('[tagTransfer] Failed to parse drag payload', error); } @@ -83,7 +95,7 @@ export const parseTagTransferPayload = (input) => { return null; }; -export const isTagTransferEvent = (event) => { +export const isTagTransferEvent = (event?: DragEventLike | null): boolean => { const types = event?.dataTransfer?.types; if (!types) { return false; diff --git a/frontend/src/documents/useEntryPointer.js b/frontend/src/documents/useEntryPointer.js deleted file mode 100644 index 88cca78..0000000 --- a/frontend/src/documents/useEntryPointer.js +++ /dev/null @@ -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; diff --git a/frontend/src/documents/useEntryPointer.ts b/frontend/src/documents/useEntryPointer.ts new file mode 100644 index 0000000..ad18f8b --- /dev/null +++ b/frontend/src/documents/useEntryPointer.ts @@ -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; diff --git a/frontend/src/documents/useInlineRename.js b/frontend/src/documents/useInlineRename.js deleted file mode 100644 index 55e6589..0000000 --- a/frontend/src/documents/useInlineRename.js +++ /dev/null @@ -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; diff --git a/frontend/src/documents/useInlineRename.ts b/frontend/src/documents/useInlineRename.ts new file mode 100644 index 0000000..14fc237 --- /dev/null +++ b/frontend/src/documents/useInlineRename.ts @@ -0,0 +1,166 @@ +import { + Dispatch, + SetStateAction, + SyntheticEvent, + useCallback, + useRef, + useState, +} from 'react'; + +type FocusableInput = (HTMLInputElement | HTMLTextAreaElement) & { + select?: () => void; +}; + +type InlineRenameOptions = { + getCurrentValue?: (entity: TEntity) => string | null | undefined; + getEntityId?: (entity: TEntity) => string | number | null | undefined; +}; + +type InlineRenameHandler = ( + id: string | number, + value: string, +) => boolean | void | Promise; + +type InlineRenameReturn = { + editingId: string | number | null; + draftValue: string; + setDraftValue: Dispatch>; + beginEditing: (entity: TEntity | null | undefined, event?: SyntheticEvent | Event) => void; + cancelEditing: (event?: SyntheticEvent | Event) => void; + submitEditing: (entity: TEntity | null | undefined) => Promise; + 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 = (entity: T) => + (entity as { id?: string | number } | null | undefined)?.id ?? null; + +const useInlineRename = ( + onRename?: InlineRenameHandler, + { + getCurrentValue = identity as (entity: TEntity) => string | null | undefined, + getEntityId = defaultGetEntityId as ( + entity: TEntity, + ) => string | number | null | undefined, + }: InlineRenameOptions = {}, +): InlineRenameReturn => { + 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: 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; diff --git a/frontend/src/documents/workspaceHeader.js b/frontend/src/documents/workspaceHeader.js deleted file mode 100644 index 6ce7e10..0000000 --- a/frontend/src/documents/workspaceHeader.js +++ /dev/null @@ -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; diff --git a/frontend/src/documents/workspaceHeader.tsx b/frontend/src/documents/workspaceHeader.tsx new file mode 100644 index 0000000..badd0d7 --- /dev/null +++ b/frontend/src/documents/workspaceHeader.tsx @@ -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; diff --git a/frontend/src/hooks/documents/store/useDocumentsStore.js b/frontend/src/hooks/documents/store/useDocumentsStore.js deleted file mode 100644 index 08cac6e..0000000 --- a/frontend/src/hooks/documents/store/useDocumentsStore.js +++ /dev/null @@ -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; diff --git a/frontend/src/hooks/documents/store/useDocumentsStore.ts b/frontend/src/hooks/documents/store/useDocumentsStore.ts new file mode 100644 index 0000000..fd31444 --- /dev/null +++ b/frontend/src/hooks/documents/store/useDocumentsStore.ts @@ -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(null); + + const setStatusMessage = useCallback((message?: string | null, variant: StatusVariant = 'info') => { + setStatus(message ? { message, variant } : null); + }, []); + + return { status, setStatusMessage }; +}; + +export default useDocumentsStore; diff --git a/frontend/src/hooks/documents/useAuthManager.js b/frontend/src/hooks/documents/useAuthManager.ts similarity index 62% rename from frontend/src/hooks/documents/useAuthManager.js rename to frontend/src/hooks/documents/useAuthManager.ts index c25684a..c56510e 100644 --- a/frontend/src/hooks/documents/useAuthManager.js +++ b/frontend/src/hooks/documents/useAuthManager.ts @@ -1,4 +1,52 @@ import { useCallback, useEffect, useRef } from 'react'; +import type { MutableRefObject } from 'react'; +import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { AxiosHeaders } from 'axios'; + +type AppStatus = string; + +type AppDispatch = (action: { type: string; [key: string]: unknown }) => void; + +type NotifyApiError = (error: unknown, fallbackMessage: string, variant?: string) => void; + +type SetStatusMessage = (message: string, variant?: string) => void; + +type SetLoading = (state: boolean) => void; + +interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig { + _retry?: boolean; +} + +interface UseAuthManagerArgs { + apiClient: AxiosInstance; + token?: string | null; + appStatus: AppStatus; + appDispatch: AppDispatch; + notifyApiError: NotifyApiError; + setStatusMessage: SetStatusMessage; + setLoading: SetLoading; +} + +interface UseAuthManagerResult { + tokenRef: MutableRefObject; + refreshAccessToken: () => Promise; + handleLogout: () => Promise; +} + +const ensureAxiosHeaders = ( + headers?: InternalAxiosRequestConfig['headers'], +): AxiosHeaders => { + if (headers instanceof AxiosHeaders) { + return headers; + } + return AxiosHeaders.from(headers || {}); +}; + +const setHeaderAuthorization = (config: InternalAxiosRequestConfig, token: string): void => { + const headers = ensureAxiosHeaders(config.headers); + headers.set('Authorization', `Bearer ${token}`); + config.headers = headers; +}; const useAuthManager = ({ apiClient, @@ -8,16 +56,16 @@ const useAuthManager = ({ notifyApiError, setStatusMessage, setLoading, -}) => { - const tokenRef = useRef(token); - const refreshPromiseRef = useRef(null); +}: UseAuthManagerArgs): UseAuthManagerResult => { + const tokenRef = useRef(token); + const refreshPromiseRef = useRef | null>(null); const initialRefreshAttemptedRef = useRef(Boolean(token)); - const refreshAccessToken = useCallback(async () => { + const refreshAccessToken = useCallback(async (): Promise => { console.log('[Auth] Attempting to refresh access token…'); appDispatch({ type: 'TOKEN_REFRESH_START' }); try { - const { data } = await apiClient.post('/auth/refresh'); + const { data } = await apiClient.post<{ access_token?: string; tenant?: unknown }>('/auth/refresh'); if (data?.access_token) { appDispatch({ type: 'TOKEN_REFRESH_SUCCESS', @@ -30,7 +78,7 @@ const useAuthManager = ({ throw new Error('Missing access token in refresh response'); } catch (error) { console.warn('[Auth] Failed to refresh access token', error); - appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null }); + appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null }); throw error; } }, [apiClient, appDispatch]); @@ -51,10 +99,11 @@ const useAuthManager = ({ const requestInterceptor = apiClient.interceptors.request.use((config) => { const currentToken = tokenRef.current; if (currentToken) { - config.headers = config.headers || {}; - if (!config.headers.Authorization) { - config.headers.Authorization = `Bearer ${currentToken}`; + const headers = ensureAxiosHeaders(config.headers); + if (!headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${currentToken}`); } + config.headers = headers; } return config; }); @@ -62,7 +111,8 @@ const useAuthManager = ({ const responseInterceptor = apiClient.interceptors.response.use( (response) => response, async (error) => { - const { response, config } = error; + const axiosError = error as AxiosError & { config?: RetryableAxiosRequestConfig }; + const { response, config } = axiosError; if (!response || !config) { return Promise.reject(error); } @@ -90,13 +140,12 @@ const useAuthManager = ({ throw new Error('No token returned from refresh'); } config._retry = true; - config.headers = config.headers || {}; - config.headers.Authorization = `Bearer ${newToken}`; + setHeaderAuthorization(config, newToken); console.log('[Auth] Retrying original request', url); try { return await apiClient(config); } catch (retryError) { - if (retryError?.response?.status === 401) { + if ((retryError as AxiosError)?.response?.status === 401) { notifyApiError(retryError, 'Session expired. Please log in again.'); } throw retryError; diff --git a/frontend/src/hooks/documents/useCorrespondents.js b/frontend/src/hooks/documents/useCorrespondents.ts similarity index 75% rename from frontend/src/hooks/documents/useCorrespondents.js rename to frontend/src/hooks/documents/useCorrespondents.ts index 501a09b..6630742 100644 --- a/frontend/src/hooks/documents/useCorrespondents.js +++ b/frontend/src/hooks/documents/useCorrespondents.ts @@ -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; + mapDocumentCaches?: (mapper: (doc: any) => any) => void; +} const useCorrespondents = ({ apiClient, @@ -6,8 +27,8 @@ const useCorrespondents = ({ setStatusMessage, tenantIdRef, mapDocumentCaches, -}) => { - const [correspondents, setCorrespondents] = useState([]); +}: UseCorrespondentsOptions) => { + const [correspondents, setCorrespondents] = useState([]); const refreshCorrespondents = useCallback(async () => { const requestTenantId = tenantIdRef.current; @@ -26,12 +47,12 @@ const useCorrespondents = ({ }, [apiClient, notifyApiError, tenantIdRef]); const handleCorrespondentUpdate = useCallback( - async (correspondentId, changes) => { + async (correspondentId: string | number | null | undefined, changes: { name?: string }) => { if (!correspondentId) { throw new Error('Missing correspondent identifier.'); } - const payload = {}; + const payload: Record = {}; if (typeof changes?.name?.trim === 'function') { const trimmed = changes.name.trim(); if (!trimmed) { @@ -59,7 +80,7 @@ const useCorrespondents = ({ ); const handleCorrespondentCreate = useCallback( - async ({ name }) => { + async ({ name }: { name?: string }) => { const trimmed = name?.trim?.() || ''; if (!trimmed) { throw new Error('Correspondent name is required.'); @@ -79,12 +100,12 @@ const useCorrespondents = ({ ); const handleCorrespondentDelete = useCallback( - async (correspondentId) => { + async (correspondentId: string | number | null | undefined) => { if (!correspondentId) { throw new Error('Missing correspondent identifier.'); } - const stripFromDoc = (doc) => { + const stripFromDoc = (doc: any) => { if (!doc || !Array.isArray(doc.correspondents)) { return doc; } diff --git a/frontend/src/hooks/documents/useDocumentCorrespondentActions.js b/frontend/src/hooks/documents/useDocumentCorrespondentActions.ts similarity index 72% rename from frontend/src/hooks/documents/useDocumentCorrespondentActions.js rename to frontend/src/hooks/documents/useDocumentCorrespondentActions.ts index 93c7476..d44c695 100644 --- a/frontend/src/hooks/documents/useDocumentCorrespondentActions.js +++ b/frontend/src/hooks/documents/useDocumentCorrespondentActions.ts @@ -1,5 +1,25 @@ import { useCallback, useMemo } from 'react'; +type ApiClient = { + post: (path: string, body?: unknown) => Promise<{ data: unknown }>; + delete: (path: string) => Promise<{ data: unknown }>; +}; + +interface CorrespondentOption { + id?: string | number; + name?: string; + [key: string]: unknown; +} + +interface UseDocumentCorrespondentActionsArgs { + apiClient: ApiClient; + correspondents: CorrespondentOption[]; + handleCorrespondentCreate: (payload: { name: string }) => Promise; + refreshCurrentFolder: () => Promise; + notifyApiError: (error: unknown, fallback: string) => void; + setStatusMessage: (message: string, variant?: string) => void; +} + const useDocumentCorrespondentActions = ({ apiClient, correspondents, @@ -7,9 +27,9 @@ const useDocumentCorrespondentActions = ({ refreshCurrentFolder, notifyApiError, setStatusMessage, -}) => { +}: UseDocumentCorrespondentActionsArgs) => { const correspondentLookupByName = useMemo(() => { - const map = new Map(); + const map = new Map(); correspondents.forEach((correspondent) => { if (correspondent?.name) { map.set(correspondent.name.toLowerCase(), correspondent); @@ -19,7 +39,10 @@ const useDocumentCorrespondentActions = ({ }, [correspondents]); const handleDocumentCorrespondentAttach = useCallback( - async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => { + async ( + { documentId, correspondentId }: { documentId?: string | number | null; correspondentId?: string | number | null }, + { notify = true, refresh = true }: { notify?: boolean; refresh?: boolean } = {}, + ) => { if (!documentId || !correspondentId) { throw new Error('Missing document or correspondent.'); } @@ -45,7 +68,10 @@ const useDocumentCorrespondentActions = ({ ); const handleCorrespondentRemove = useCallback( - async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => { + async ( + { documentId, correspondentId }: { documentId?: string | number | null; correspondentId?: string | number | null }, + { notify = true, refresh = true }: { notify?: boolean; refresh?: boolean } = {}, + ) => { if (!documentId || !correspondentId) { throw new Error('Missing document or correspondent.'); } @@ -68,7 +94,7 @@ const useDocumentCorrespondentActions = ({ ); const handleCorrespondentAdd = useCallback( - async ({ document, name, input = null, option = null }) => { + async ({ document, name, input = null, option = null }: { document?: { id?: string | number }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => { if (!document?.id) { throw new Error('Missing document for correspondent assignment.'); } diff --git a/frontend/src/hooks/documents/useDocumentDragHandlers.js b/frontend/src/hooks/documents/useDocumentDragHandlers.ts similarity index 82% rename from frontend/src/hooks/documents/useDocumentDragHandlers.js rename to frontend/src/hooks/documents/useDocumentDragHandlers.ts index 6d0c2c9..6cce03b 100644 --- a/frontend/src/hooks/documents/useDocumentDragHandlers.js +++ b/frontend/src/hooks/documents/useDocumentDragHandlers.ts @@ -1,4 +1,38 @@ import { useCallback, useEffect, useRef } from 'react'; +import type { DragEvent } from 'react'; + +type Identifier = string | number; +type FolderIdentifier = Identifier | 'root'; + +interface DocumentLike { + id?: Identifier | null; + title?: string; + [key: string]: unknown; +} + +type ApplySelectionFn = ( + keys: string[], + options?: { anchor?: string | null; interactedKeys?: string[] }, +) => void; + +type HandleEntrySelectionFn = ( + key: string, + event: { preventDefault?: () => void }, +) => void; + +interface UseDocumentDragHandlersOptions { + selectedEntries: string[]; + selectedDocumentIds: Identifier[]; + selectedFolderIds: FolderIdentifier[]; + applySelection: ApplySelectionFn; + handleEntrySelection: HandleEntrySelectionFn; + documentLookup: Map; + setDraggedDocumentIds: (ids: Identifier[] | []) => void; + setDraggedFolderId: (id: FolderIdentifier | null) => void; + resolveDocumentRowKey: (id: Identifier) => string | null | undefined; + resolveFolderRowKey: (id: FolderIdentifier) => string | null | undefined; + documentsViewMode: string; +} const useDocumentDragHandlers = ({ selectedEntries, @@ -12,8 +46,8 @@ const useDocumentDragHandlers = ({ resolveDocumentRowKey, resolveFolderRowKey, documentsViewMode, -}) => { - const dragPreviewRef = useRef(null); +}: UseDocumentDragHandlersOptions) => { + const dragPreviewRef = useRef(null); const destroyDragPreview = useCallback(() => { const node = dragPreviewRef.current; @@ -26,7 +60,7 @@ const useDocumentDragHandlers = ({ useEffect(() => destroyDragPreview, [destroyDragPreview]); const createDragPreview = useCallback( - ({ documents = [], folders = [] } = {}) => { + ({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: Array } = {}) => { destroyDragPreview(); const docEntries = (documents || []).filter(Boolean); @@ -155,8 +189,11 @@ const useDocumentDragHandlers = ({ ); const handleDocumentDragStart = useCallback( - (event, documentOrId) => { - const documentId = documentOrId?.id ?? (typeof documentOrId?.trim === 'function' ? documentOrId : null); + (event: DragEvent, documentOrId: DocumentLike | Identifier | null | undefined) => { + const documentId = + (documentOrId as DocumentLike)?.id ?? (typeof documentOrId === 'string' || typeof documentOrId === 'number' + ? documentOrId + : null); if (!documentId) { return; } @@ -168,12 +205,12 @@ const useDocumentDragHandlers = ({ const isGridView = documentsViewMode === 'grid'; const isAlreadySelected = selectedDocumentIds.includes(documentId); - const selection = isAlreadySelected + const selection: Identifier[] = isAlreadySelected ? [...selectedDocumentIds] : isGridView ? [...selectedDocumentIds, documentId] : [documentId]; - const folderSelection = []; + const folderSelection: FolderIdentifier[] = []; if (!isAlreadySelected && !isGridView) { applySelection([documentKey], { @@ -182,7 +219,9 @@ const useDocumentDragHandlers = ({ }); } - const previewDocs = selection.map((id) => documentLookup.get(id) || null).filter(Boolean); + const previewDocs = selection + .map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null) + .filter(Boolean); const previewNode = createDragPreview({ documents: previewDocs, folders: folderSelection, @@ -230,7 +269,7 @@ const useDocumentDragHandlers = ({ ); const handleDocumentDragEnd = useCallback( - (event) => { + (event: DragEvent) => { setDraggedDocumentIds([]); event.currentTarget.classList.remove('dragging'); destroyDragPreview(); @@ -240,7 +279,7 @@ const useDocumentDragHandlers = ({ ); const handleFolderDragStart = useCallback( - (event, folderId) => { + (event: DragEvent, folderId: FolderIdentifier) => { if (folderId === 'root') { return; } @@ -248,8 +287,8 @@ const useDocumentDragHandlers = ({ const folderKey = resolveFolderRowKey(folderId); const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false; - let effectiveFolderSelection = selectedFolderIds; - let effectiveDocumentSelection = selectedDocumentIds; + let effectiveFolderSelection: FolderIdentifier[] = selectedFolderIds; + let effectiveDocumentSelection: Identifier[] = selectedDocumentIds; if (!isAlreadySelected && folderKey) { effectiveFolderSelection = [folderId]; @@ -287,7 +326,7 @@ const useDocumentDragHandlers = ({ const previewNode = createDragPreview({ documents: effectiveDocumentSelection - .map((id) => documentLookup.get(id) || null) + .map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null) .filter(Boolean), folders: uniqueFolders, }); @@ -313,7 +352,7 @@ const useDocumentDragHandlers = ({ ); const handleFolderDragEnd = useCallback( - (event) => { + (event?: DragEvent) => { if (event?.currentTarget) { event.currentTarget.classList.remove('dragging'); } diff --git a/frontend/src/hooks/documents/useDocumentMutations.js b/frontend/src/hooks/documents/useDocumentMutations.ts similarity index 62% rename from frontend/src/hooks/documents/useDocumentMutations.js rename to frontend/src/hooks/documents/useDocumentMutations.ts index cb8309b..1b552f2 100644 --- a/frontend/src/hooks/documents/useDocumentMutations.js +++ b/frontend/src/hooks/documents/useDocumentMutations.ts @@ -1,12 +1,182 @@ import { useCallback } from 'react'; +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils'; -const normalizeDocumentId = (value) => { +type DocumentId = string | number; +type FolderId = DocumentId | 'root'; +type NullableFolderId = FolderId | null; + +type StatusLevel = 'success' | 'error' | 'info' | string; + +type DocumentCacheMapper = ( + doc: DocumentLike | null | undefined, +) => DocumentLike | null | undefined; + +type MapDocumentCaches = (mapper: DocumentCacheMapper) => void; + +type UpdateDocumentCaches = ( + documentId: DocumentId, + updater: DocumentCacheMapper, +) => void; + +type EnsureFolderData = ( + folderId: FolderId, + options?: { force?: boolean; includeDocuments?: boolean; prefetchDepth?: number }, +) => Promise; + +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(url: string, data?: unknown, config?: Record): Promise<{ data: T }>; + patch(url: string, data?: unknown, config?: Record): Promise<{ data: T }>; + delete(url: string, config?: Record): 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; +} + +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; + folderLabelMap: Map; + ensureFolderData: EnsureFolderData; + selectedFolder: FolderId; + setSelectedFolder: Dispatch>; + setDocuments: Dispatch>; + setFolderContents: Dispatch>>; + setSearchResults: Dispatch>; + setSelectedEntries: Dispatch>; + setSelectionOrder: Dispatch>; + selectionOrderRef: MutableRefObject; + selectionAnchorRef: MutableRefObject; + setFocusedDocumentId: Dispatch>; + focusedDocumentId: DocumentId | null; + setFocusedRowKey: Dispatch>; + focusedRowKey: string | null; + notifyApiError: NotifyApiError; + setStatusMessage: SetStatusMessage; + setLoading: (next: boolean) => void; + mapDocumentCaches: MapDocumentCaches; + applySelectedFolder: ApplySelectedFolder; + folderNodes: Map; + setFolderNodes: Dispatch>>; + removeDocumentsFromCaches: RemoveDocumentsFromCaches; + closeDocumentPreview: CloseDocumentPreview; + previewDocumentId?: DocumentId | null; + refreshCurrentFolder: () => Promise; + documentsViewMode?: string; + updateDocumentCaches: UpdateDocumentCaches; + tagLookupById: Map; + tags: Tag[]; + refreshTags: () => Promise; + tagManager: TagManager; + extractDocumentFromResponse?: (payload: unknown) => DocumentLike | null | undefined; +} + +interface UseDocumentMutationsResult { + moveDocumentsToFolder: ( + documentIds: Array, + targetFolderId?: NullableFolderId, + ) => Promise; + handleThumbnailRegeneration: (documentId: DocumentId) => Promise; + handleDocumentsDelete: ( + documentIds: DocumentId[], + options?: DeleteOptions, + ) => Promise; + handleDocumentTagAdd: ( + document: DocumentLike, + label: string, + extras?: DocumentTagExtras | null, + ) => Promise; + handleDocumentTagAttach: (args: TagAttachArgs) => Promise; + handleDocumentTitleUpdate: (documentId: DocumentId, nextTitle: string) => Promise; + handleDocumentIssuedUpdate: ( + documentId: DocumentId, + nextIssuedDate: number | null, + ) => Promise; + handleTagRemove: ( + documentId?: DocumentId, + tagId?: DocumentId, + options?: TagRemoveOptions, + ) => Promise; + handleFolderDelete: (folderId?: FolderId, options?: FolderDeleteOptions) => Promise; +} + +const normalizeDocumentId = (value: unknown): DocumentId | null => { if (!value) return null; - if (typeof value === 'object' && value.id) { - return value.id; + if (typeof value === 'object' && value !== null && 'id' in value && value.id != null) { + return value.id as DocumentId; } - return value; + return value as DocumentId; }; const useDocumentMutations = ({ @@ -46,36 +216,34 @@ const useDocumentMutations = ({ refreshTags, tagManager, extractDocumentFromResponse, -}) => { +}: UseDocumentMutationsArgs): UseDocumentMutationsResult => { const moveDocumentsToFolder = useCallback( - async (documentIds, targetFolderId) => { + async (documentIds: Array, targetFolderId?: NullableFolderId) => { const uniqueIds = Array.from( - new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean)), + new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean) as DocumentId[]), ); if (!uniqueIds.length) return; const uniqueIdSet = new Set(uniqueIds); - const target = targetFolderId === 'root' ? null : targetFolderId; + const target = targetFolderId === 'root' ? null : targetFolderId ?? null; const targetLabel = - target === null - ? DEFAULT_FOLDER_NAME - : folderLabelMap.get(targetFolderId) || 'target folder'; + target === null ? DEFAULT_FOLDER_NAME : folderLabelMap.get(targetFolderId as FolderId) || 'target folder'; const movedDocs = uniqueIds .map((id) => { - const doc = documentLookup.get(id); + const doc = documentLookup.get(id) || null; if (!doc) { return null; } return { id, - sourceFolderId: doc.folder_id ?? null, + sourceFolderId: (doc.folder_id ?? null) as NullableFolderId, document: doc, }; }) - .filter(Boolean); + .filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: DocumentLike }>; - const updatedDocsMap = new Map(); + const updatedDocsMap = new Map(); const resolveTargetName = () => { if (!targetLabel) { return null; @@ -89,7 +257,7 @@ const useDocumentMutations = ({ if (!document) { return; } - const updated = { + const updated: DocumentLike = { ...document, folder_id: target, }; @@ -105,13 +273,13 @@ const useDocumentMutations = ({ updatedDocsMap.set(id, updated); }); - const pruneRow = (collection) => + const pruneRow = (collection: string[]): string[] => collection.filter((key) => { if (!isDocumentRowKey(key)) { return true; } const id = getRowId(key); - return id ? !uniqueIdSet.has(id) : true; + return id ? !uniqueIdSet.has(id as DocumentId) : true; }); setLoading(true); @@ -131,10 +299,10 @@ const useDocumentMutations = ({ if (updatedDocsMap.size) { mapDocumentCaches((doc) => { - if (!doc || !uniqueIdSet.has(doc.id)) { + if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) { return doc; } - const updated = updatedDocsMap.get(doc.id); + const updated = updatedDocsMap.get(doc.id as DocumentId); if (updated) { return updated; } @@ -142,7 +310,7 @@ const useDocumentMutations = ({ }); } else { mapDocumentCaches((doc) => { - if (!doc || !uniqueIdSet.has(doc.id)) { + if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) { return doc; } return { ...doc, folder_id: target }; @@ -154,10 +322,10 @@ const useDocumentMutations = ({ if (!Array.isArray(prev) || !prev.length) { return prev; } - const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id)); + const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id as DocumentId)); return filtered.length === prev.length ? prev : filtered; }); - setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id))); + setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId))); setFolderContents((prev) => { if (!prev.size) { return prev; @@ -165,7 +333,7 @@ const useDocumentMutations = ({ let changed = false; const next = new Map(prev); movedDocs.forEach(({ id, sourceFolderId }) => { - const sourceKey = sourceFolderId || 'root'; + const sourceKey = (sourceFolderId || 'root') as FolderId; const entry = next.get(sourceKey); if (!entry?.documents?.length) { return; @@ -179,13 +347,14 @@ const useDocumentMutations = ({ return changed ? next : prev; }); - setSelectedEntries((prev) => pruneRow(prev, uniqueIdSet)); - setSelectionOrder((prev) => pruneRow(prev, uniqueIdSet)); - selectionOrderRef.current = pruneRow(selectionOrderRef.current || [], uniqueIdSet); + setSelectedEntries((prev) => pruneRow(prev)); + setSelectionOrder((prev) => pruneRow(prev)); + const nextSelectionOrder = pruneRow(selectionOrderRef.current || []); + selectionOrderRef.current = nextSelectionOrder; if ( selectionAnchorRef.current && isDocumentRowKey(selectionAnchorRef.current) && - uniqueIdSet.has(getRowId(selectionAnchorRef.current)) + uniqueIdSet.has(getRowId(selectionAnchorRef.current) as DocumentId) ) { selectionAnchorRef.current = null; } @@ -195,17 +364,17 @@ const useDocumentMutations = ({ if ( focusedRowKey && isDocumentRowKey(focusedRowKey) && - uniqueIdSet.has(getRowId(focusedRowKey)) + uniqueIdSet.has(getRowId(focusedRowKey) as DocumentId) ) { setFocusedRowKey(null); } } if (targetFolderId && targetFolderId !== selectedFolder) { - await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); + await ensureFolderData(targetFolderId as FolderId, { force: true, prefetchDepth: 1 }); } } catch (error) { - const message = error.response?.data?.error || 'Failed to move documents.'; + const message = (error as Record)?.response?.data?.error || 'Failed to move documents.'; notifyApiError(error, message); } finally { setLoading(false); @@ -236,7 +405,7 @@ const useDocumentMutations = ({ ); const handleThumbnailRegeneration = useCallback( - async (documentId) => { + async (documentId: DocumentId) => { if (!token) { setStatusMessage('Log in to manage assets.', 'error'); return; @@ -249,7 +418,7 @@ const useDocumentMutations = ({ setStatusMessage('Document re-analysis queued.', 'info'); await refreshCurrentFolder(); } catch (error) { - const message = error.response?.data?.error || 'Failed to request thumbnail generation.'; + const message = (error as Record)?.response?.data?.error || 'Failed to request thumbnail generation.'; notifyApiError(error, message); } finally { setLoading(false); @@ -259,7 +428,7 @@ const useDocumentMutations = ({ ); const handleDocumentsDelete = useCallback( - async (documentIds, { showMessage = true, manageLoading = true } = {}) => { + async (documentIds: DocumentId[], { showMessage = true, manageLoading = true }: DeleteOptions = {}) => { if (!documentIds || documentIds.length === 0) { return false; } @@ -274,13 +443,11 @@ const useDocumentMutations = ({ } try { - await Promise.all( - documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)), - ); + await Promise.all(documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`))); removeDocumentsFromCaches(documentIds); - if (documentIds.includes(previewDocumentId)) { + if (previewDocumentId && documentIds.includes(previewDocumentId)) { closeDocumentPreview(); } @@ -290,7 +457,7 @@ const useDocumentMutations = ({ } return true; } catch (error) { - const message = error.response?.data?.error || 'Failed to delete documents.'; + const message = (error as Record)?.response?.data?.error || 'Failed to delete documents.'; notifyApiError(error, message); return false; } finally { @@ -312,7 +479,7 @@ const useDocumentMutations = ({ ); const handleDocumentTitleUpdate = useCallback( - async (documentId, nextTitle) => { + async (documentId: DocumentId, nextTitle: string) => { const trimmed = nextTitle?.trim?.() || ''; if (!trimmed) { setStatusMessage('Document title cannot be empty.', 'error'); @@ -334,7 +501,7 @@ const useDocumentMutations = ({ setStatusMessage('Document title updated.', 'success'); return true; } catch (error) { - const message = error.response?.data?.error || 'Failed to update document title.'; + const message = (error as Record)?.response?.data?.error || 'Failed to update document title.'; notifyApiError(error, message); return false; } finally { @@ -345,7 +512,7 @@ const useDocumentMutations = ({ ); const handleDocumentIssuedUpdate = useCallback( - async (documentId, nextIssuedDate) => { + async (documentId: DocumentId, nextIssuedDate: number | null) => { setLoading(true); const payload = { issued_at: nextIssuedDate || null }; try { @@ -363,7 +530,7 @@ const useDocumentMutations = ({ setStatusMessage(message, 'success'); return true; } catch (error) { - const message = error.response?.data?.error || 'Failed to update issued date.'; + const message = (error as Record)?.response?.data?.error || 'Failed to update issued date.'; notifyApiError(error, message); return false; } finally { @@ -374,26 +541,23 @@ const useDocumentMutations = ({ ); const handleDocumentTagAdd = useCallback( - async (document, label, extras = null) => { + async (document: DocumentLike, label: string, extras: DocumentTagExtras | null = null) => { const normalizedLabel = tagManager.normalizeLabel(label); - const optionCandidate = - extras && typeof extras === 'object' && 'option' in extras ? extras.option : null; - const input = - extras && typeof extras === 'object' && 'input' in extras ? extras.input : null; + const optionCandidate = extras?.option ?? null; + const input = extras?.input ?? null; - let tag = null; + let tag: Tag | null = null; if (optionCandidate && optionCandidate.id) { tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate; } if (!tag) { - tag = - tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null; + tag = tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null; } try { if (!tag) { const payload = tagManager.buildPayload({ label: normalizedLabel }); const { data } = await api.post('/tags', payload); - tag = data; + tag = data as Tag; await refreshTags(); } await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] }); @@ -410,12 +574,12 @@ const useDocumentMutations = ({ ); const handleDocumentTagAttach = useCallback( - async ({ documentId, tagId, tag: tagData = null }) => { + async ({ documentId, tagId, tag: tagData = null }: TagAttachArgs) => { if (!documentId || !tagId) { return false; } - const resolveTagForCache = () => { + const resolveTagForCache = (): Tag | null => { const lookupTag = tagLookupById.get(tagId); const source = lookupTag ?? tagData; if (!source || source.id == null || typeof source.label?.trim !== 'function') { @@ -424,7 +588,7 @@ const useDocumentMutations = ({ return { id: source.id, label: source.label, - color: Object.prototype.hasOwnProperty.call(source, 'color') ? source.color : null, + color: Object.prototype.hasOwnProperty.call(source, 'color') ? (source as Tag).color ?? null : null, }; }; @@ -450,7 +614,7 @@ const useDocumentMutations = ({ } return true; } catch (error) { - const message = error.response?.data?.error || 'Failed to assign tag.'; + const message = (error as Record)?.response?.data?.error || 'Failed to assign tag.'; notifyApiError(error, message); return false; } @@ -467,16 +631,16 @@ const useDocumentMutations = ({ ); const applyTagRemovalToCaches = useCallback( - (documentId, tagId) => { + (documentId?: DocumentId, tagId?: DocumentId) => { if (!documentId || !tagId) { return; } updateDocumentCaches(documentId, (doc) => { - if (!Array.isArray(doc.tags)) { + if (!doc || !Array.isArray(doc.tags)) { return doc; } - const nextTags = doc.tags.filter((tag) => tag.id !== tagId); + const nextTags = doc.tags.filter((tagEntry) => tagEntry.id !== tagId); if (nextTags.length === doc.tags.length) { return doc; } @@ -487,7 +651,11 @@ const useDocumentMutations = ({ ); const handleTagRemove = useCallback( - async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => { + async ( + documentId?: DocumentId, + tagId?: DocumentId, + { refreshTagList = true, showMessage = true }: TagRemoveOptions = {}, + ) => { if (!documentId || !tagId) { return false; } @@ -503,7 +671,7 @@ const useDocumentMutations = ({ } return true; } catch (error) { - const message = error.response?.data?.error || 'Failed to remove tag.'; + const message = (error as Record)?.response?.data?.error || 'Failed to remove tag.'; notifyApiError(error, message); return false; } @@ -512,7 +680,7 @@ const useDocumentMutations = ({ ); const handleFolderDelete = useCallback( - async (folderId, { showMessage = true, manageLoading = true } = {}) => { + async (folderId?: FolderId, { showMessage = true, manageLoading = true }: FolderDeleteOptions = {}) => { if (!token) { if (showMessage) { setStatusMessage('Log in to manage folders.', 'error'); @@ -589,7 +757,7 @@ const useDocumentMutations = ({ } return true; } catch (error) { - const message = error.response?.data?.error || 'Failed to delete folder.'; + const message = (error as Record)?.response?.data?.error || 'Failed to delete folder.'; notifyApiError(error, message); if (showMessage) { setStatusMessage(message, 'error'); diff --git a/frontend/src/hooks/documents/useDocumentTagging.js b/frontend/src/hooks/documents/useDocumentTagging.ts similarity index 75% rename from frontend/src/hooks/documents/useDocumentTagging.js rename to frontend/src/hooks/documents/useDocumentTagging.ts index b8982f1..e103c30 100644 --- a/frontend/src/hooks/documents/useDocumentTagging.js +++ b/frontend/src/hooks/documents/useDocumentTagging.ts @@ -1,5 +1,47 @@ import { useCallback } from 'react'; +type Identifier = string | number; + +interface TagRecord { + id?: Identifier; + label: string; + [key: string]: unknown; +} + +interface ApiClient { + post: (path: string, payload: unknown) => Promise<{ data: T } | T>; +} + +interface TagManager { + buildPayload: (input: { label: string }) => Record; +} + +interface UseDocumentTaggingArgs { + apiClient: ApiClient; + tags: TagRecord[]; + tagManager: TagManager; + refreshTags: () => Promise | void; + refreshCurrentFolder: () => Promise | void; + resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[]; + notifyApiError: (error: unknown, message: string) => void; + setStatusMessage: (message: string, variant?: string) => void; + setLoading: (state: boolean) => void; +} + +interface BulkTagOperationArgs { + labels: string[]; + action: 'add' | 'remove'; + documentIds?: Identifier[]; +} + +interface BulkTagOperationResult { + ok: boolean; + reason?: 'no-labels' | 'no-selection' | 'tag-missing' | 'no-tags' | 'request-failed'; + label?: string; + tagCount?: number; + docsCount?: number; +} + const useDocumentTagging = ({ apiClient, tags, @@ -10,9 +52,9 @@ const useDocumentTagging = ({ notifyApiError, setStatusMessage, setLoading, -}) => { +}: UseDocumentTaggingArgs) => { const bulkTagOperation = useCallback( - async ({ labels, action, documentIds }) => { + async ({ labels, action, documentIds }: BulkTagOperationArgs): Promise => { const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0); if (!normalized.length) { return { ok: false, reason: 'no-labels' }; @@ -22,7 +64,7 @@ const useDocumentTagging = ({ return { ok: false, reason: 'no-selection' }; } - let tagIds = []; + let tagIds: Identifier[] = []; if (action === 'remove') { const missing = normalized.find( @@ -41,13 +83,13 @@ const useDocumentTagging = ({ setLoading(true); try { if (action === 'add') { - const createdIds = []; + const createdIds: Identifier[] = []; for (const label of normalized) { let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null; if (!tag) { const payload = tagManager.buildPayload({ label }); - const { data } = await apiClient.post('/tags', payload); - tag = data; + const response = await apiClient.post<{ id?: Identifier; label: string }>('/tags', payload); + tag = 'data' in response ? response.data : response; await refreshTags(); } createdIds.push(tag.id); @@ -97,7 +139,7 @@ const useDocumentTagging = ({ ); const handleBulkTagAddFromDetail = useCallback( - async ({ label, input, documentIds }) => { + async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => { const trimmed = label?.trim?.() || ''; if (!trimmed) { setStatusMessage('Enter a tag label.', 'error'); @@ -130,7 +172,7 @@ const useDocumentTagging = ({ ); const handleBulkTagRemoveFromDetail = useCallback( - async ({ label, input, documentIds }) => { + async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => { const trimmed = label?.trim?.() || ''; if (!trimmed) { setStatusMessage('Enter a tag label to remove.', 'error'); @@ -163,7 +205,7 @@ const useDocumentTagging = ({ ); const handleBulkSelectionReanalyze = useCallback( - async (documentIdsOverride = null) => { + async (documentIdsOverride: Identifier[] | null = null) => { const targetIds = resolveTargetDocumentIds(documentIdsOverride); if (!targetIds.length) { setStatusMessage('Select documents before requesting re-analysis.', 'error'); diff --git a/frontend/src/hooks/documents/useDocumentUploads.js b/frontend/src/hooks/documents/useDocumentUploads.ts similarity index 68% rename from frontend/src/hooks/documents/useDocumentUploads.js rename to frontend/src/hooks/documents/useDocumentUploads.ts index cb3c2a1..238a206 100644 --- a/frontend/src/hooks/documents/useDocumentUploads.js +++ b/frontend/src/hooks/documents/useDocumentUploads.ts @@ -1,8 +1,76 @@ import { useCallback, useRef, useState } from 'react'; +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import useFileDrop from './useFileDrop'; import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils'; -const mapFilesToEntries = (filesInput) => { +type Identifier = string | number; +type FolderId = Identifier | 'root' | null; + +type FileEntry = { + file: File; + segments: string[]; +}; + +type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error'; + +type UploadQueueItem = { + id: string; + name: string; + size: number | null; + folderId: FolderId; + status: UploadStatus; + error: string | null; + code: number | null; + document: unknown; + conflictDocumentId: Identifier | null; +}; + +interface UploadResponse { + reused?: boolean; + document?: unknown; +} + +interface ApiClient { + post(url: string, payload: unknown): Promise<{ data: T; status?: number }>; + get(url: string): Promise<{ data: T }>; +} + +type DropOverlayState = { + active: boolean; + folderName: string; +}; + +type FileSystemEntryLike = FileSystemFileEntryLike | FileSystemDirectoryEntryLike; + +type ExtendedDataTransferItem = DataTransferItem & { + webkitGetAsEntry?: () => FileSystemEntryLike | null; +}; + +interface FileSystemDirectoryReaderLike { + readEntries: ( + successCallback: (entries: FileSystemEntryLike[]) => void, + errorCallback: (error: DOMException) => void, + ) => void; +} + +interface FileSystemFileEntryLike { + isFile: true; + isDirectory: false; + name: string; + file: ( + successCallback: (file: File) => void, + errorCallback: (error: DOMException) => void, + ) => void; +} + +interface FileSystemDirectoryEntryLike { + isFile: false; + isDirectory: true; + name: string; + createReader: () => FileSystemDirectoryReaderLike; +} + +const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] => { if (!filesInput) { return []; } @@ -10,7 +78,7 @@ const mapFilesToEntries = (filesInput) => { return files .filter(Boolean) .map((file) => { - const relativePath = file?.webkitRelativePath ?? ''; + const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? ''; const segments = relativePath ? relativePath .split('/') @@ -21,6 +89,35 @@ const mapFilesToEntries = (filesInput) => { }); }; +interface UseDocumentUploadsArgs { + apiClient: ApiClient; + token?: string | null; + selectedFolder?: FolderId; + currentFolderName?: string | null; + ensureFolderData: (folderId: FolderId, options?: { force?: boolean; prefetchDepth?: number }) => Promise; + refreshCurrentFolder: () => Promise; + setLoading: (state: boolean) => void; + shellRef: MutableRefObject; +} + +interface UseDocumentUploadsResult { + dropOverlayState: DropOverlayState; + setDropOverlayState: Dispatch>; + dragCounterRef: MutableRefObject; + handleFileDrop: (dataTransfer: DataTransfer, targetFolderId?: FolderId) => Promise; + handleFileSelection: (files?: FileList | null, targetFolderId?: FolderId) => Promise; + uploadFile: (file: File, targetFolderId: FolderId) => Promise<{ + document: unknown; + duplicate: boolean; + statusCode: number | null; + conflictDocumentId: Identifier | null; + }>; + extractFilesFromDataTransfer: (dataTransfer: DataTransfer) => Promise; + resetUploadsState: () => void; + uploadQueue: UploadQueueItem[]; + clearUploadQueue: () => void; +} + const useDocumentUploads = ({ apiClient, token, @@ -30,18 +127,18 @@ const useDocumentUploads = ({ refreshCurrentFolder, setLoading, shellRef, -}) => { - const [dropOverlayState, setDropOverlayState] = useState({ +}: UseDocumentUploadsArgs): UseDocumentUploadsResult => { + const [dropOverlayState, setDropOverlayState] = useState({ active: false, - folderName: DEFAULT_FOLDER_NAME, + folderName: currentFolderName || DEFAULT_FOLDER_NAME, }); const dragCounterRef = useRef(0); - const folderPathCacheRef = useRef(new Map()); + const folderPathCacheRef = useRef>(new Map()); const queueIdRef = useRef(0); - const [uploadQueue, setUploadQueue] = useState([]); + const [uploadQueue, setUploadQueue] = useState([]); const uploadFile = useCallback( - async (file, targetFolderId) => { + async (file: File, targetFolderId: FolderId) => { if (!file || file.size === 0) { return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null }; } @@ -62,14 +159,14 @@ const useDocumentUploads = ({ statusCode: status ?? (duplicate ? 200 : 201), conflictDocumentId: null, }; - } catch (error) { + } catch (error: any) { if (error.response?.status === 409) { const conflictId = error.response?.data?.details?.conflict_document_id ?? null; let conflictDocument = null; if (conflictId) { try { const { data } = await apiClient.get(`/documents/${conflictId}`); - conflictDocument = data?.document ?? data ?? null; + conflictDocument = (data as any)?.document ?? data ?? null; } catch (fetchError) { console.warn('[Uploads] failed to fetch conflict document', fetchError); } @@ -89,7 +186,7 @@ const useDocumentUploads = ({ [apiClient], ); - const appendQueueItems = useCallback((entries, targetFolderId) => { + const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => { const baseId = Date.now(); const items = entries.map(({ file }) => { queueIdRef.current += 1; @@ -98,12 +195,12 @@ const useDocumentUploads = ({ name: file?.name || 'Unnamed file', size: file?.size ?? null, folderId: targetFolderId ?? selectedFolder ?? 'root', - status: 'pending', + status: 'pending' as UploadStatus, error: null, code: null, document: null, conflictDocumentId: null, - }; + } satisfies UploadQueueItem; }); if (items.length) { setUploadQueue((current) => [...current, ...items]); @@ -111,7 +208,7 @@ const useDocumentUploads = ({ return items; }, [selectedFolder]); - const updateQueueItem = useCallback((id, patch) => { + const updateQueueItem = useCallback((id: string, patch: Partial) => { if (!id) { return; } @@ -121,7 +218,7 @@ const useDocumentUploads = ({ }, []); const ensureFolderPathOnServer = useCallback( - async (baseFolderId, segments) => { + async (baseFolderId: FolderId, segments: string[]): Promise => { const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean); if (trimmedSegments.length === 0) { return baseFolderId ?? null; @@ -130,7 +227,7 @@ const useDocumentUploads = ({ const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`; const cache = folderPathCacheRef.current; if (cache.has(cacheKey)) { - return cache.get(cacheKey); + return cache.get(cacheKey) ?? null; } const payload = { @@ -145,21 +242,21 @@ const useDocumentUploads = ({ [apiClient], ); - const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => { + const extractFilesFromDataTransfer = useCallback(async (dataTransfer: DataTransfer) => { if (!dataTransfer) { throw new Error('No drop payload found.'); } - const items = Array.from(dataTransfer.items || []); + const items = Array.from(dataTransfer.items || []) as ExtendedDataTransferItem[]; console.info('[Uploads] drop start', { items: items.length, files: (dataTransfer.files || []).length, }); - const results = []; + const results: FileEntry[] = []; const seenKeys = new Set(); - const pushFile = (file, ancestors = []) => { + const pushFile = (file?: File | null, ancestors: string[] = []) => { if (!file) return; const segments = (ancestors || []).filter(Boolean); const key = `${segments.join('/')}/${file.name}:${file.size}`; @@ -170,11 +267,11 @@ const useDocumentUploads = ({ results.push({ file, segments }); }; - const readAllEntries = async (reader) => { - const entries = []; - let batch = []; + const readAllEntries = async (reader: FileSystemDirectoryReaderLike) => { + const entries: FileSystemEntryLike[] = []; + let batch: FileSystemEntryLike[] = []; do { - batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); + batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); if (batch.length) { entries.push(...batch); } @@ -182,15 +279,15 @@ const useDocumentUploads = ({ return entries; }; - const walkEntry = async (entry, ancestors = []) => { + const walkEntry = async (entry: FileSystemEntryLike | null, ancestors: string[] = []) => { if (!entry) return; if (entry.isFile) { - const file = await new Promise((resolve, reject) => { + const file = await new Promise((resolve, reject) => { try { - entry.file(resolve, reject); + (entry as FileSystemFileEntryLike).file(resolve, reject); } catch (error) { console.warn('[Uploads] entry.file failed', error); - reject(error); + reject(error as Error); } }); pushFile(file, ancestors); @@ -198,7 +295,7 @@ const useDocumentUploads = ({ } if (entry.isDirectory) { const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors]; - const reader = entry.createReader(); + const reader = (entry as FileSystemDirectoryEntryLike).createReader(); const entries = await readAllEntries(reader); for (const child of entries) { await walkEntry(child, nextAncestors); @@ -212,7 +309,7 @@ const useDocumentUploads = ({ const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null; if (fileFromItem) { - const relativePath = fileFromItem?.webkitRelativePath ?? ''; + const relativePath = (fileFromItem as File & { webkitRelativePath?: string })?.webkitRelativePath ?? ''; const segments = relativePath ? relativePath .split('/') @@ -242,7 +339,7 @@ const useDocumentUploads = ({ Array.from(dataTransfer.files || []).forEach((file) => { if (!file) return; - const relativePath = file?.webkitRelativePath ?? ''; + const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? ''; const segments = relativePath ? relativePath .split('/') @@ -322,7 +419,7 @@ const useDocumentUploads = ({ updateQueueItem(queueItem.id, patch); Object.assign(queueItem, patch); } - } catch (error) { + } catch (error: any) { if (queueItem) { const patch = { status: 'error', @@ -345,7 +442,7 @@ const useDocumentUploads = ({ ) { await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); } - } catch (error) { + } catch (error: any) { const message = error.message || 'Failed to upload files.'; queueItems.forEach((item) => { if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') { @@ -378,8 +475,8 @@ const useDocumentUploads = ({ ); const handleFileDrop = useCallback( - async (dataTransfer, targetFolderId) => { - let extracted; + async (dataTransfer: DataTransfer, targetFolderId?: FolderId) => { + let extracted: FileEntry[]; try { extracted = await extractFilesFromDataTransfer(dataTransfer); } catch (error) { @@ -393,7 +490,7 @@ const useDocumentUploads = ({ ); const handleFileSelection = useCallback( - async (files, targetFolderId) => { + async (files?: FileList | null, targetFolderId?: FolderId) => { const entries = mapFilesToEntries(files); await uploadFileEntries(entries, targetFolderId); }, @@ -434,7 +531,7 @@ const useDocumentUploads = ({ resetUploadsState, uploadQueue, clearUploadQueue, - }; + } satisfies UseDocumentUploadsResult; }; export default useDocumentUploads; diff --git a/frontend/src/hooks/documents/useDocuments.js b/frontend/src/hooks/documents/useDocuments.ts similarity index 70% rename from frontend/src/hooks/documents/useDocuments.js rename to frontend/src/hooks/documents/useDocuments.ts index 206eb50..7cdf594 100644 --- a/frontend/src/hooks/documents/useDocuments.js +++ b/frontend/src/hooks/documents/useDocuments.ts @@ -1,17 +1,33 @@ -import { useCallback, useState } from 'react'; +import { Dispatch, SetStateAction, useCallback, useState } from 'react'; -const useDocuments = ({ setSearchResults, setFolderContents }) => { - const [documents, setDocuments] = useState([]); +interface DocumentLike { + id?: string | number; + [key: string]: unknown; +} + +interface FolderContentsEntry { + documents?: DocumentLike[]; + [key: string]: unknown; +} + +interface UseDocumentsOptions { + setSearchResults: Dispatch>; + setFolderContents: Dispatch>>; +} + +const useDocuments = ({ setSearchResults, setFolderContents }: UseDocumentsOptions) => { + const [documents, setDocuments] = useState([]); const mapDocumentCaches = useCallback( - (mapper) => { + (mapper: (doc: DocumentLike) => DocumentLike | undefined) => { if (typeof mapper !== 'function') { return; } - const applyToList = (list) => { + const applyToList = (list?: DocumentLike[] | null) => { let changed = false; - const next = list.map((doc) => { + const safeList = Array.isArray(list) ? list : []; + const next = safeList.map((doc) => { const updated = mapper(doc); if (updated === undefined || updated === doc) { return doc; @@ -19,7 +35,7 @@ const useDocuments = ({ setSearchResults, setFolderContents }) => { changed = true; return updated; }); - return changed ? next : list; + return changed ? next : safeList; }; setDocuments((prev) => applyToList(prev)); diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.js b/frontend/src/hooks/documents/useDocumentsWorkspace.ts similarity index 98% rename from frontend/src/hooks/documents/useDocumentsWorkspace.js rename to frontend/src/hooks/documents/useDocumentsWorkspace.ts index 6fcb5e0..62480d0 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.js +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { matchPath, useLocation, @@ -57,6 +57,22 @@ const EntryType = Object.freeze({ const noop = () => {}; +interface UseDocumentsWorkspaceOptions { + documentsViewMode?: string; + documentsSortField?: string; + documentsSortDirection?: string; + documentsSortFieldRef?: MutableRefObject; + documentsSortDirectionRef?: MutableRefObject; + onDocumentsViewModeChange?: (mode: string) => void; + onDocumentsSortFieldChange?: (field: string) => void; + onDocumentsSortDirectionToggle?: () => void; + searchIncludeDescendants?: boolean; + onToggleSearchIncludeDescendants?: () => void; + onSetSearchIncludeDescendants?: (value: boolean) => void; + sortRefreshReadyRef?: MutableRefObject; + handleDeskExit?: () => void; +} + const useDocumentsWorkspace = ({ documentsViewMode = 'list', documentsSortField = DEFAULT_SORT_FIELD, @@ -71,7 +87,7 @@ const useDocumentsWorkspace = ({ onSetSearchIncludeDescendants, sortRefreshReadyRef, handleDeskExit, -} = {}) => { +}: UseDocumentsWorkspaceOptions = {}) => { const handleDocumentsViewModeChange = onDocumentsViewModeChange || noop; const handleDocumentsSortFieldChange = onDocumentsSortFieldChange || noop; const handleDocumentsSortDirectionToggle = onDocumentsSortDirectionToggle || noop; diff --git a/frontend/src/hooks/documents/useFileDrop.js b/frontend/src/hooks/documents/useFileDrop.ts similarity index 70% rename from frontend/src/hooks/documents/useFileDrop.js rename to frontend/src/hooks/documents/useFileDrop.ts index aaa9e9e..668b112 100644 --- a/frontend/src/hooks/documents/useFileDrop.js +++ b/frontend/src/hooks/documents/useFileDrop.ts @@ -1,4 +1,21 @@ -import { useEffect } from 'react'; +import { MutableRefObject, useEffect } from 'react'; + +interface DropOverlayState { + active: boolean; + folderName: string | null; +} + +interface UseFileDropOptions { + shellRef: MutableRefObject; + token?: string | null; + currentFolderName: string | null; + selectedFolder: string | null; + handleFileDrop: (dataTransfer: DataTransfer, folderId: string | null) => Promise; + hasFiles: (event: DragEvent) => boolean; + defaultFolderName: string; + dragCounterRef: MutableRefObject; + setDropOverlayState: (updater: ((prev: DropOverlayState) => DropOverlayState) | DropOverlayState) => void; +} const useFileDrop = ({ shellRef, @@ -10,7 +27,7 @@ const useFileDrop = ({ defaultFolderName, dragCounterRef, setDropOverlayState, -}) => { +}: UseFileDropOptions) => { useEffect(() => { if (!token) { @@ -19,20 +36,20 @@ const useFileDrop = ({ return undefined; } - const handleDragEnter = (event) => { + const handleDragEnter = (event: DragEvent) => { if (!hasFiles(event)) return; event.preventDefault(); dragCounterRef.current += 1; setDropOverlayState({ active: true, folderName: currentFolderName }); }; - const handleDragOver = (event) => { + const handleDragOver = (event: DragEvent) => { if (!hasFiles(event)) return; event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; }; - const handleDragLeave = (event) => { + const handleDragLeave = (event: DragEvent) => { if (!hasFiles(event)) return; dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); if (dragCounterRef.current === 0) { @@ -40,7 +57,7 @@ const useFileDrop = ({ } }; - const handleDrop = async (event) => { + const handleDrop = async (event: DragEvent) => { if (!hasFiles(event)) return; event.preventDefault(); dragCounterRef.current = 0; diff --git a/frontend/src/hooks/documents/useFolderTree.js b/frontend/src/hooks/documents/useFolderTree.ts similarity index 100% rename from frontend/src/hooks/documents/useFolderTree.js rename to frontend/src/hooks/documents/useFolderTree.ts diff --git a/frontend/src/hooks/documents/useFolderTreeActions.js b/frontend/src/hooks/documents/useFolderTreeActions.ts similarity index 80% rename from frontend/src/hooks/documents/useFolderTreeActions.js rename to frontend/src/hooks/documents/useFolderTreeActions.ts index 2c53bb9..4d268a5 100644 --- a/frontend/src/hooks/documents/useFolderTreeActions.js +++ b/frontend/src/hooks/documents/useFolderTreeActions.ts @@ -1,6 +1,88 @@ import { useCallback, useMemo } from 'react'; +import type { DragEvent } from 'react'; import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils'; +type FolderId = string | number; +type FolderKey = FolderId | 'root'; + +interface FolderNode { + id: FolderKey; + name?: string; + parentId?: FolderKey | null; + children: FolderId[]; + expanded?: boolean; + loaded?: boolean; + hasChildren?: boolean; +} + +interface FolderContentsState { + folder?: { id: FolderKey; name?: string }; + subfolders?: any[]; + documents?: any[]; + [key: string]: unknown; +} + +interface ApiClient { + patch: (url: string, data?: unknown) => Promise; + post: (url: string, data?: unknown) => Promise<{ data: any }>; + delete: (url: string) => Promise; +} + +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; + onSelect: (folderId: FolderKey, options?: SelectFolderOptions) => Promise; + onDrop: (event: DragEvent, folderId: FolderKey) => Promise; + onDragOver: (event: DragEvent, folderId: FolderKey) => void; + onDragLeave: (event: DragEvent) => void; +} + +interface UseFolderTreeActionsOptions { + api: ApiClient; + token?: string | null; + folderNodes: Map; + setFolderNodes: (updater: (prev: Map) => Map) => void; + selectedFolder: FolderKey; + setSelectedFolder: (folderId: FolderKey) => void; + ensureFolderData: (folderId: FolderKey, options?: EnsureFolderOptions) => Promise; + ensureFolderAncestorsLoaded: (folderId: FolderKey) => Promise; + 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) => Map, + ) => 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; + moveDocumentsToFolder: (docIds: FolderId[], folderId: FolderKey) => Promise; + draggedDocumentIds: FolderId[]; + draggedFolderId: FolderKey | null; + setDraggedDocumentIds: (ids: FolderId[]) => void; + setDraggedFolderId: (id: FolderKey | null) => void; + isInvalidFolderDrop: (sourceFolderId: FolderKey, targetFolderId: FolderKey) => boolean; + setCreatingFolder: (value: boolean) => void; +} + const useFolderTreeActions = ({ api, token, @@ -28,9 +110,9 @@ const useFolderTreeActions = ({ setDraggedFolderId, isInvalidFolderDrop, setCreatingFolder, -}) => { +}: UseFolderTreeActionsOptions) => { const moveFolder = useCallback( - async (folderId, targetFolderId) => { + async (folderId: FolderKey, targetFolderId: FolderKey | null | undefined) => { const node = folderNodes.get(folderId); if (!node) { setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error'); @@ -95,9 +177,11 @@ const useFolderTreeActions = ({ }); const refreshTargets = new Set([previousParentKey, targetKey]); - for (const key of refreshTargets) { - await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }); - } + await Promise.all( + Array.from(refreshTargets).map((key) => + ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }), + ), + ); if (selectedFolder === folderId) { await ensureFolderData(folderId, { force: true, prefetchDepth: 1 }); @@ -110,9 +194,11 @@ const useFolderTreeActions = ({ notifyApiError(error, message); const refreshTargets = new Set([previousParentKey, targetKey]); - for (const key of refreshTargets) { - await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }); - } + await Promise.all( + Array.from(refreshTargets).map((key) => + ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }), + ), + ); } }, [ @@ -128,7 +214,7 @@ const useFolderTreeActions = ({ ); const loadFolder = useCallback( - async (folderId, { showLoading = true, preserveSearch = false } = {}) => { + async (folderId: FolderKey | null | undefined, { showLoading = true, preserveSearch = false }: LoadFolderOptions = {}) => { const targetId = folderId || 'root'; setSelectedFolder(targetId); await ensureFolderAncestorsLoaded(targetId); @@ -170,7 +256,7 @@ const useFolderTreeActions = ({ ); const selectFolder = useCallback( - async (folderId, { replace = false, immediate = false } = {}) => { + async (folderId: FolderKey | null | undefined, { replace = false, immediate = false }: SelectFolderOptions = {}) => { const targetId = folderId && folderId !== 'root' ? folderId : 'root'; await ensureFolderAncestorsLoaded(targetId); @@ -196,7 +282,7 @@ const useFolderTreeActions = ({ ); const handleFolderRename = useCallback( - async (folderId, nextName) => { + async (folderId: FolderKey, nextName: string) => { if (!token) { setStatusMessage('Log in to rename folders.', 'error'); return false; @@ -257,7 +343,7 @@ const useFolderTreeActions = ({ ); const handleFolderCreate = useCallback( - async (name) => { + async (name: string) => { if (!token) { setStatusMessage('Log in to create folders.', 'error'); return false; @@ -327,7 +413,7 @@ const useFolderTreeActions = ({ ); const handleFolderDelete = useCallback( - async (folderId, { showMessage = true, manageLoading = true } = {}) => { + async (folderId: FolderKey, { showMessage = true, manageLoading = true }: { showMessage?: boolean; manageLoading?: boolean } = {}) => { if (!token) { if (showMessage) { setStatusMessage('Log in to manage folders.', 'error'); @@ -432,9 +518,9 @@ const useFolderTreeActions = ({ ], ); - const folderClickHandlers = useMemo( + const folderClickHandlers: FolderClickHandlers = useMemo( () => ({ - onToggle: async (folderId) => { + onToggle: async (folderId: FolderKey) => { const node = folderNodes.get(folderId); const nextExpanded = !(node?.expanded ?? false); if (nextExpanded) { @@ -465,12 +551,12 @@ const useFolderTreeActions = ({ }); }, onSelect: selectFolder, - onDrop: async (event, folderId) => { + onDrop: async (event: DragEvent, folderId: FolderKey) => { event.preventDefault(); event.stopPropagation(); event.currentTarget.classList.remove('is-drop-target'); - let folderIds = []; + let folderIds: FolderId[] = []; try { const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list'); if (rawFolderList) { @@ -523,7 +609,7 @@ const useFolderTreeActions = ({ return; } - let docIds = []; + let docIds: FolderId[] = []; try { const raw = event.dataTransfer.getData('application/x-papercrate-doc-list'); if (raw) { @@ -560,7 +646,7 @@ const useFolderTreeActions = ({ setDraggedDocumentIds([]); await moveDocumentsToFolder(docIds, folderId); }, - onDragOver: (event, folderId) => { + onDragOver: (event: DragEvent, folderId: FolderKey) => { const folderDragActive = Boolean(draggedFolderId); if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) { return; @@ -579,7 +665,7 @@ const useFolderTreeActions = ({ event.currentTarget.classList.add('is-drop-target'); } }, - onDragLeave: (event) => { + onDragLeave: (event: DragEvent) => { event.currentTarget.classList.remove('is-drop-target'); }, }), diff --git a/frontend/src/hooks/documents/useTags.js b/frontend/src/hooks/documents/useTags.ts similarity index 68% rename from frontend/src/hooks/documents/useTags.js rename to frontend/src/hooks/documents/useTags.ts index 604bcc9..e12adcf 100644 --- a/frontend/src/hooks/documents/useTags.js +++ b/frontend/src/hooks/documents/useTags.ts @@ -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; + setActiveTagFilters: (updater: (prev: Array) => Array) => void; + mapDocumentCaches?: (mapper: (doc: any) => any) => void; +} const useTags = ({ apiClient, @@ -8,8 +36,8 @@ const useTags = ({ tenantIdRef, setActiveTagFilters, mapDocumentCaches, -}) => { - const [tags, setTags] = useState([]); +}: UseTagsOptions) => { + const [tags, setTags] = useState([]); const refreshTags = useCallback(async () => { const requestTenantId = tenantIdRef.current; @@ -28,12 +56,12 @@ const useTags = ({ }, [apiClient, notifyApiError, tenantIdRef]); const handleTagUpdate = useCallback( - async (tagId, changes) => { + async (tagId: string | number | null | undefined, changes: { label?: string; color?: string | null }) => { if (!tagId) { throw new Error('Missing tag identifier.'); } - const payload = {}; + const payload: Record = {}; if (typeof changes?.label?.trim === 'function') { payload.label = changes.label; } @@ -60,7 +88,7 @@ const useTags = ({ ); const handleTagCreate = useCallback( - async ({ label, color } = {}) => { + async ({ label, color }: { label?: string; color?: string | null } = {}) => { const payload = tagManager.buildPayload({ label, color }); try { await apiClient.post('/tags', payload); @@ -76,7 +104,7 @@ const useTags = ({ ); const handleTagDelete = useCallback( - async (tagId) => { + async (tagId: string | number | null | undefined) => { if (!tagId) { throw new Error('Missing tag identifier.'); } @@ -85,7 +113,7 @@ const useTags = ({ await apiClient.delete(`/tags/${tagId}`); setActiveTagFilters((prev) => prev.filter((id) => id !== tagId)); - const stripTagFromDoc = (doc) => { + const stripTagFromDoc = (doc: any) => { if (!doc || !Array.isArray(doc.tags)) { return doc; } diff --git a/frontend/src/hooks/documents/useTenantManager.js b/frontend/src/hooks/documents/useTenantManager.ts similarity index 67% rename from frontend/src/hooks/documents/useTenantManager.js rename to frontend/src/hooks/documents/useTenantManager.ts index ec67f67..4efab52 100644 --- a/frontend/src/hooks/documents/useTenantManager.js +++ b/frontend/src/hooks/documents/useTenantManager.ts @@ -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 } }; +} + +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; + refreshCorrespondents: () => Promise; + loadFolder: (folderId: string, options?: { showLoading?: boolean; preserveSearch?: boolean }) => Promise; + handleDocumentsViewModeChange: (mode: string) => void; + navigate: NavigateFunction; + tokenRef?: MutableRefObject; + tenantIdRef?: MutableRefObject; +} const useTenantManager = ({ apiClient, @@ -15,9 +44,9 @@ const useTenantManager = ({ navigate, tokenRef, tenantIdRef, -}) => { +}: UseTenantManagerOptions) => { const handleTenantSelect = useCallback( - async (tenantOption, { refreshOnly = false } = {}) => { + async (tenantOption: TenantOption | null, { refreshOnly = false }: { refreshOnly?: boolean } = {}) => { const requestedTenantId = tenantOption?.id ?? null; if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) { return; diff --git a/frontend/src/hooks/useApiError.js b/frontend/src/hooks/useApiError.js deleted file mode 100644 index 6ec26bb..0000000 --- a/frontend/src/hooks/useApiError.js +++ /dev/null @@ -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; diff --git a/frontend/src/hooks/useApiError.ts b/frontend/src/hooks/useApiError.ts new file mode 100644 index 0000000..ae9ba47 --- /dev/null +++ b/frontend/src/hooks/useApiError.ts @@ -0,0 +1,50 @@ +import { useCallback } from 'react'; + +type ApiLogger = Pick; + +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; diff --git a/frontend/src/hooks/useAssetNavigator.js b/frontend/src/hooks/useAssetNavigator.js deleted file mode 100644 index a4a9f3e..0000000 --- a/frontend/src/hooks/useAssetNavigator.js +++ /dev/null @@ -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; diff --git a/frontend/src/hooks/useAssetNavigator.ts b/frontend/src/hooks/useAssetNavigator.ts new file mode 100644 index 0000000..37cbd59 --- /dev/null +++ b/frontend/src/hooks/useAssetNavigator.ts @@ -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 | null; + [key: string]: unknown; +} + +interface AssetLike { + id?: Identifier; + cardinality?: number; + url?: string | null; + metadata?: Record | null; + objects?: AssetObject[]; + [key: string]: unknown; +} + +type EnsureAssetUrl = ( + documentId: Identifier, + asset: AssetLike, + options?: { start?: number; limit?: number; [key: string]: unknown }, +) => Promise; + +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 | 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(() => { + if (!document || typeof getAsset !== 'function') { + return null; + } + return getAsset(document, assetType) || null; + }, [document, assetType, getAsset]); + + const view = useMemo( + () => 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(() => { + 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; diff --git a/frontend/src/index.html b/frontend/src/index.html index d5cc24a..84aa91a 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -2,7 +2,7 @@ - + Papercrate diff --git a/frontend/src/index.jsx b/frontend/src/index.tsx similarity index 86% rename from frontend/src/index.jsx rename to frontend/src/index.tsx index 4e334ad..7128cc5 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.tsx @@ -8,6 +8,11 @@ import { AppStateProvider } from './app/appState'; import AppRouter from './app/AppRouter'; const container = document.getElementById('app'); + +if (!container) { + throw new Error('App root element #app not found'); +} + const root = createRoot(container); root.render( diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.ts similarity index 100% rename from frontend/src/lib/api.js rename to frontend/src/lib/api.ts diff --git a/frontend/src/login/LoginView.jsx b/frontend/src/login/LoginView.tsx similarity index 79% rename from frontend/src/login/LoginView.jsx rename to frontend/src/login/LoginView.tsx index fc96729..8ee4d1b 100644 --- a/frontend/src/login/LoginView.jsx +++ b/frontend/src/login/LoginView.tsx @@ -1,11 +1,40 @@ import React, { useEffect, useState } from 'react'; -const StatusBanner = ({ status }) => { +interface StatusBannerProps { + status?: { message: string; variant: string } | null; +} + +const StatusBanner: React.FC = ({ status }) => { if (!status) return null; return
{status.message}
; }; -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 = ({ status, tenantSelection, onSelectTenant, @@ -41,7 +70,7 @@ const LoginView = ({ onSignup(username); }; - const handleSubmit = (event) => { + const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); if (!passkeySupported || passkeyLoading || magicLoginPending || !username.trim()) { return; @@ -57,7 +86,7 @@ const LoginView = ({

Select a tenant to finish signing in.

- {tenantSelection.tenants.map((tenant) => ( + {tenantSelection?.tenants?.map((tenant) => ( ) : null; diff --git a/frontend/src/preview/useViewerLayoutMode.js b/frontend/src/preview/useViewerLayoutMode.ts similarity index 81% rename from frontend/src/preview/useViewerLayoutMode.js rename to frontend/src/preview/useViewerLayoutMode.ts index 8698899..4b14b03 100644 --- a/frontend/src/preview/useViewerLayoutMode.js +++ b/frontend/src/preview/useViewerLayoutMode.ts @@ -1,4 +1,4 @@ -import { useLayoutEffect, useState } from 'react'; +import { MutableRefObject, useLayoutEffect, useState } from 'react'; export const DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO = 0.4; const PORTRAIT_RATIO_STYLE_ID = 'document-viewer-portrait-ratio-style'; @@ -19,9 +19,14 @@ const ensurePortraitRatioStyle = () => { } }; -const computeStackedLayoutBreakpoint = () => window.innerWidth / 2; +const MIN_STACKED_BREAKPOINT = 480; -export const useViewerLayoutMode = (ref, dependency) => { +const computeStackedLayoutBreakpoint = () => Math.max(window.innerWidth / 2, MIN_STACKED_BREAKPOINT); + +export const useViewerLayoutMode = ( + ref: MutableRefObject | null, + dependency?: unknown, +) => { const [isStacked, setIsStacked] = useState(false); useLayoutEffect(() => { @@ -35,8 +40,8 @@ export const useViewerLayoutMode = (ref, dependency) => { return undefined; } - let frame = null; - const commitMeasure = (width) => { + let frame: number | null = null; + const commitMeasure = (width: number) => { if (frame) { cancelAnimationFrame(frame); } diff --git a/frontend/src/routes/DocumentViewerRoute.jsx b/frontend/src/routes/DocumentViewerRoute.tsx similarity index 96% rename from frontend/src/routes/DocumentViewerRoute.jsx rename to frontend/src/routes/DocumentViewerRoute.tsx index 44a0569..e6334da 100644 --- a/frontend/src/routes/DocumentViewerRoute.jsx +++ b/frontend/src/routes/DocumentViewerRoute.tsx @@ -3,7 +3,7 @@ import { Navigate, useNavigate, useParams } from 'react-router-dom'; import { useAppShell } from '../appShellContext'; -const DocumentViewerRoute = () => { +const DocumentViewerRoute: React.FC = () => { const { previewWorkspaceDocument, ensurePreviewData, diff --git a/frontend/src/settings/SettingsModal.jsx b/frontend/src/settings/SettingsModal.tsx similarity index 85% rename from frontend/src/settings/SettingsModal.jsx rename to frontend/src/settings/SettingsModal.tsx index 589c13a..64a4985 100644 --- a/frontend/src/settings/SettingsModal.jsx +++ b/frontend/src/settings/SettingsModal.tsx @@ -1,4 +1,5 @@ import React, { + ReactNode, useCallback, useEffect, useMemo, @@ -7,18 +8,33 @@ import React, { import PanelHeader from '../ui/PanelHeader'; import { DEFAULT_SETTINGS_SECTIONS } from './sections'; -const SettingsModal = ({ +export interface SettingsSectionConfig { + id: string; + label: string; + component?: React.ComponentType; + render?: (props: Record) => ReactNode; +} + +interface SettingsModalProps { + open?: boolean; + onClose?: () => void; + sections?: SettingsSectionConfig[]; + defaultSectionId?: string; + [key: string]: unknown; +} + +const SettingsModal: React.FC = ({ open = false, onClose, sections, defaultSectionId, ...sectionProps }) => { - const sectionList = useMemo(() => { + const sectionList: SettingsSectionConfig[] = useMemo(() => { if (Array.isArray(sections) && sections.length) { return sections; } - return DEFAULT_SETTINGS_SECTIONS; + return DEFAULT_SETTINGS_SECTIONS as SettingsSectionConfig[]; }, [sections]); const firstSectionId = sectionList[0]?.id ?? null; diff --git a/frontend/src/settings/components/CapabilityDropdown.jsx b/frontend/src/settings/components/CapabilityDropdown.tsx similarity index 55% rename from frontend/src/settings/components/CapabilityDropdown.jsx rename to frontend/src/settings/components/CapabilityDropdown.tsx index c3784b0..8129755 100644 --- a/frontend/src/settings/components/CapabilityDropdown.jsx +++ b/frontend/src/settings/components/CapabilityDropdown.tsx @@ -1,11 +1,51 @@ -import React, { +import { useCallback, useEffect, + useMemo, useRef, useState, } from 'react'; +import type { JSX } from 'react'; import { CheckIcon, ChevronDownIcon } from '../../ui/icons'; +type CapabilityValue = string | number; + +export interface CapabilityDropdownOption { + value?: CapabilityValue | null; + id?: CapabilityValue | null; + label?: string; +} + +interface CapabilityDropdownProps { + id?: string; + options?: Array; + selectedValues?: CapabilityValue[]; + onSelect?: (value: CapabilityValue) => void; + onDeselect?: (value: CapabilityValue) => void; + formatLabel?: (value: CapabilityValue) => string; + disabled?: boolean; + loading?: boolean; + summaryLabel?: string; +} + +const resolveCapabilityValue = ( + option: CapabilityDropdownOption | CapabilityValue | null | undefined, +): CapabilityValue | null => { + if (option == null) { + return null; + } + if (typeof option === 'string' || typeof option === 'number') { + return option; + } + if (option.value != null) { + return option.value; + } + if (option.id != null) { + return option.id; + } + return null; +}; + const CapabilityDropdown = ({ id, options = [], @@ -16,9 +56,9 @@ const CapabilityDropdown = ({ disabled = false, loading = false, summaryLabel = 'capabilities', -}) => { - const anchorRef = useRef(null); - const menuRef = useRef(null); +}: CapabilityDropdownProps): JSX.Element => { + const anchorRef = useRef(null); + const menuRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const close = useCallback(() => { @@ -37,14 +77,17 @@ const CapabilityDropdown = ({ return undefined; } - const handlePointerEvent = (event) => { - if (anchorRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) { + const handlePointerEvent = (event: MouseEvent | TouchEvent) => { + if (anchorRef.current?.contains(event.target as Node)) { + return; + } + if (menuRef.current?.contains(event.target as Node)) { return; } close(); }; - const handleKeyDown = (event) => { + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { close(); } @@ -61,7 +104,10 @@ const CapabilityDropdown = ({ }; }, [close, isOpen]); - const handleOptionClick = useCallback((value) => { + const handleOptionClick = useCallback((value: CapabilityValue | null) => { + if (value == null) { + return; + } if (selectedValues.includes(value)) { onDeselect?.(value); } else { @@ -69,26 +115,27 @@ const CapabilityDropdown = ({ } }, [onDeselect, onSelect, selectedValues]); - const total = options.length; + const derivedOptions = useMemo(() => options.filter(Boolean), [options]); + const total = derivedOptions.length; const selectedCount = selectedValues.length; - const summaryText = total - ? `${selectedCount}/${total} ${summaryLabel} enabled` - : selectedCount - ? `${selectedCount} ${summaryLabel} selected` - : loading - ? `Loading ${summaryLabel}…` - : `No ${summaryLabel}`; - - const buttonText = selectedCount || loading || total - ? summaryText - : `Select ${summaryLabel}`; - - const emptyMessage = loading - ? `Loading ${summaryLabel}…` - : `No ${summaryLabel} available.`; + const summaryText = useMemo(() => { + if (total) { + return `${selectedCount}/${total} ${summaryLabel} enabled`; + } + if (selectedCount) { + return `${selectedCount} ${summaryLabel} selected`; + } + if (loading) { + return `Loading ${summaryLabel}…`; + } + return `No ${summaryLabel}`; + }, [loading, selectedCount, summaryLabel, total]); + const buttonText = total || selectedCount || loading ? summaryText : `Select ${summaryLabel}`; + const emptyMessage = loading ? `Loading ${summaryLabel}…` : `No ${summaryLabel} available.`; const isDisabled = disabled || (total === 0 && !selectedCount) || loading; + const menuId = id ? `${id}-menu` : undefined; return (
@@ -97,7 +144,7 @@ const CapabilityDropdown = ({ className="capability-dropdown__trigger" aria-haspopup="menu" aria-expanded={isOpen} - aria-controls={id ? `${id}-menu` : undefined} + aria-controls={menuId} onClick={toggle} disabled={isDisabled} ref={anchorRef} @@ -107,18 +154,21 @@ const CapabilityDropdown = ({ {isOpen ? ( - {isExpanded && node.children.length > 0 && ( + {isExpanded && childIds.length > 0 && (
    - {renderChildren(node.children, depth + 1)} + {renderChildren(childIds, depth + 1)}
)} @@ -151,7 +244,16 @@ const THEME_MODE_LABELS = { dark: 'Dark', }; -const Sidebar = ({ +interface FloatingMenuControls { + isOpen: boolean; + toggle: () => void; + close: () => void; + menuRef: React.RefObject; + menuStyle: CSSProperties | null; + updatePosition: () => void; +} + +const Sidebar: React.FC = ({ folderNodes, onToggle, onSelect, @@ -160,10 +262,10 @@ const Sidebar = ({ onDragLeave, onDeleteFolder, onRenameFolder, - selectedFolder, + selectedFolder = null, onFolderDragStart, onFolderDragEnd, - draggedFolderId, + draggedFolderId = null, onCreateFolder, creatingFolder = false, tags = [], @@ -181,9 +283,9 @@ const Sidebar = ({ onSearchChange, onSearchSubmit, onSearchClear, - isFilterActive, + isFilterActive = false, onLogout, - status, + status = null, tenantName, tenants = [], activeTenantId = null, @@ -209,8 +311,8 @@ const Sidebar = ({ sidebarSuppressed, collapseSidebar, } = usePanelManager(); - const uploadInputRef = useRef(null); - const sidebarRef = useRef(null); + const uploadInputRef = useRef(null); + const sidebarRef = useRef(null); const { panelStyle: sidebarStyle, handleProps: sidebarHandleProps, @@ -223,24 +325,36 @@ const Sidebar = ({ const neutralHueInputId = useId(); const neutralChromaInputId = useId(); const neutralContrastInputId = useId(); - const sortedCorrespondents = useMemo(() => { + const sortedCorrespondents = useMemo(() => { if (!Array.isArray(correspondents)) { return []; } return correspondents - .filter((entry) => entry?.name) + .filter((entry): entry is CorrespondentEntry & { name: string } => Boolean(entry?.name)) .slice() - .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + .sort((a, b) => a.name!.localeCompare(b.name!, undefined, { sensitivity: 'base' })); }, [correspondents]); const activeCorrespondentSet = useMemo( - () => new Set(activeCorrespondentIds || []), + () => new Set(activeCorrespondentIds || []), [activeCorrespondentIds], ); - const handleToggleTag = onToggleTagFilter || (() => {}); - const activeTagSet = new Set(activeTagIds); + const handleToggleTag = useCallback( + (tagId: Identifier | null | undefined) => { + onToggleTagFilter?.(tagId); + }, + [onToggleTagFilter], + ); + const activeTagSet = useMemo( + () => new Set(activeTagIds || []), + [activeTagIds], + ); const untaggedActive = untaggedFilterId ? activeTagSet.has(untaggedFilterId) : false; - const handleManageTags = onManageTags || (() => {}); - const handleManageCorrespondents = onManageCorrespondents || (() => {}); + const handleManageTags = useCallback(() => { + onManageTags?.(); + }, [onManageTags]); + const handleManageCorrespondents = useCallback(() => { + onManageCorrespondents?.(); + }, [onManageCorrespondents]); const handleCreateTag = useCallback(async () => { const input = window.prompt('New tag name'); if (!input) { @@ -252,7 +366,7 @@ const Sidebar = ({ } try { await onCreateTag?.(trimmed); - } catch (error) { + } catch (error: unknown) { console.error('[sidebar] failed to create tag', error); } }, [onCreateTag]); @@ -268,7 +382,7 @@ const Sidebar = ({ } try { await onCreateCorrespondent?.(trimmed); - } catch (error) { + } catch (error: unknown) { console.error('[sidebar] failed to create correspondent', error); } }, [onCreateCorrespondent]); @@ -288,10 +402,10 @@ const Sidebar = ({ }, [onUploadFiles]); const handleUploadInputChange = useCallback( - (event) => { + (event: React.ChangeEvent) => { const files = event.target?.files; if (files && files.length && onUploadFiles) { - onUploadFiles(files, selectedFolder); + onUploadFiles(files, selectedFolder ?? 'root'); } if (event.target) { event.target.value = ''; @@ -307,7 +421,7 @@ const Sidebar = ({ }, [resetNeutralHue, resetNeutralChroma, resetNeutralContrast]); const handleNeutralHueChange = useCallback( - (value) => { + (value: string) => { if (value === '') { resetNeutralHue(); return; @@ -322,7 +436,7 @@ const Sidebar = ({ ); const handleNeutralChromaChange = useCallback( - (value) => { + (value: string) => { if (value === '') { resetNeutralChroma(); return; @@ -337,7 +451,7 @@ const Sidebar = ({ ); const handleNeutralContrastChange = useCallback( - (value) => { + (value: string) => { if (value === '') { resetNeutralContrast(); return; @@ -351,16 +465,16 @@ const Sidebar = ({ [resetNeutralContrast, setNeutralContrast], ); - const formatSliderValue = useCallback((value) => { + const formatSliderValue = useCallback((value: number | string) => { const parsed = Number(value); if (Number.isNaN(parsed)) { - return value; + return String(value); } return parsed.toFixed(2); }, []); - const themeModeList = themeModes && themeModes.length ? themeModes : THEME_MODES; - const themeModeIndex = themeModeList.indexOf(themeMode); + const themeModeList = Array.isArray(themeModes) && themeModes.length ? themeModes : THEME_MODES; + const themeModeIndex = themeModeList.indexOf(themeMode as string); const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex; const resolvedThemeMode = themeModeList[safeThemeModeIndex] || THEME_MODES[0]; const nextThemeMode = themeModeList[(safeThemeModeIndex + 1) % themeModeList.length]; @@ -450,13 +564,13 @@ const Sidebar = ({ : null; const handleSearchInputChange = useCallback( - (event) => { + (event: React.ChangeEvent) => { onSearchChange?.(event.target.value); }, [onSearchChange], ); const handleSearchFormSubmit = useCallback( - (event) => { + (event: React.FormEvent) => { event.preventDefault(); onSearchSubmit?.(); }, @@ -465,7 +579,7 @@ const Sidebar = ({ const handleSearchClear = useCallback(() => { onSearchClear?.(); }, [onSearchClear]); - const tenantButtonRef = useRef(null); + const tenantButtonRef = useRef(null); const { isOpen: tenantMenuOpen, toggle: toggleTenantMenuFloating, @@ -477,7 +591,7 @@ const Sidebar = ({ anchorRef: tenantButtonRef, minWidth: 220, offset: 6, - }); + }) as FloatingMenuControls; const showTenantList = tenants.length > 1; const menuClassName = `menu${!showTenantList && !themeMenuSection ? ' menu--simple' : ''}`; @@ -495,7 +609,7 @@ const Sidebar = ({ }, [tenantMenuOpen, refreshTenantMenuPosition, tenants.length]); const handleTenantSelect = useCallback( - (tenant) => { + (tenant: TenantOption | null) => { const targetId = tenant?.id || null; if (!targetId) { return; @@ -517,7 +631,7 @@ const Sidebar = ({ }, [closeTenantMenu, onOpenSettings]); const renderNodes = useCallback( - (ids, depth) => + (ids: FolderIdentifier[], depth: number): ReactNode => ids.map((id) => { const node = folderNodes.get(id); if (!node) return null; @@ -527,7 +641,7 @@ const Sidebar = ({ node={node} depth={depth} isSelected={selectedFolder === id} - onToggle={() => onToggle(id)} + onToggle={onToggle} onSelect={onSelect} onDrop={onDrop} onDragOver={onDragOver} @@ -566,6 +680,65 @@ const Sidebar = ({ sidebarClassNames.push('sidebar--suppressed'); } + const tenantMenuContent = tenantMenuOpen + && tenantMenuStyle + && typeof document !== 'undefined' + ? createPortal( +
+ {showTenantList ? ( +
+
Switch tenant
+
+ {tenants.map((tenant) => { + const tenantId = tenant?.id || null; + const isActive = tenantId === activeTenantId; + const tenantLabel = tenant?.name || tenantId || 'Tenant'; + return ( + + ); + })} +
+
+ ) : null} +
+
+ + +
+
+ {themeMenuSection} +
, + document.body, + ) + : null; + return (