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..a8365a8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,16 +11,22 @@ "@fontsource/inter": "^5.2.8", "@tabler/icons-react": "^3.35.0", "axios": "^1.13.2", + "pdfjs-dist": "^5.4.394", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-redux": "^9.2.0", "react-router-dom": "^7.9.5" }, "devDependencies": { "@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", + "copy-webpack-plugin": "^13.0.1", "css-loader": "^7.1.2", "dotenv": "^17.2.3", "eslint": "^9.39.1", @@ -29,6 +35,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 +190,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 +302,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 +1548,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 +1766,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 +1776,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 +2310,229 @@ "dev": true, "license": "MIT" }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.82.tgz", + "integrity": "sha512-FGjyUBoF0sl1EenSiE4UV2WYu76q6F9GSYedq5EiOCOyGYoQ/Owulcv6rd7v/tWOpljDDtefXXIaOCJrVKem4w==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.82", + "@napi-rs/canvas-darwin-arm64": "0.1.82", + "@napi-rs/canvas-darwin-x64": "0.1.82", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.82", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.82", + "@napi-rs/canvas-linux-arm64-musl": "0.1.82", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.82", + "@napi-rs/canvas-linux-x64-gnu": "0.1.82", + "@napi-rs/canvas-linux-x64-musl": "0.1.82", + "@napi-rs/canvas-win32-x64-msvc": "0.1.82" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.82.tgz", + "integrity": "sha512-bvZhN0iI54ouaQOrgJV96H2q7J3ZoufnHf4E1fUaERwW29Rz4rgicohnAg4venwBJZYjGl5Yl3CGmlAl1LZowQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.82.tgz", + "integrity": "sha512-InuBHKCyuFqhNwNr4gpqazo5Xp6ltKflqOLiROn4hqAS8u21xAHyYCJRgHwd+a5NKmutFTaRWeUIT/vxWbU/iw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.82.tgz", + "integrity": "sha512-aQGV5Ynn96onSXcuvYb2y7TRXD/t4CL2EGmnGqvLyeJX1JLSNisKQlWN/1bPDDXymZYSdUqbXehj5qzBlOx+RQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.82.tgz", + "integrity": "sha512-YIUpmHWeHGGRhWitT1KJkgj/JPXPfc9ox8oUoyaGPxolLGPp5AxJkq8wIg8CdFGtutget968dtwmx71m8o3h5g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.82.tgz", + "integrity": "sha512-AwLzwLBgmvk7kWeUgItOUor/QyG31xqtD26w1tLpf4yE0hiXTGp23yc669aawjB6FzgIkjh1NKaNS52B7/qEBQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.82.tgz", + "integrity": "sha512-moZWuqepAwWBffdF4JDadt8TgBD02iMhG6I1FHZf8xO20AsIp9rB+p0B8Zma2h2vAF/YMjeFCDmW5un6+zZz9g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.82.tgz", + "integrity": "sha512-w9++2df2kG9eC9LWYIHIlMLuhIrKGQYfUxs97CwgxYjITeFakIRazI9LYWgVzEc98QZ9x9GQvlicFsrROV59MQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.82.tgz", + "integrity": "sha512-lZulOPwrRi6hEg/17CaqdwWEUfOlIJuhXxincx1aVzsVOCmyHf+xFq4i6liJl1P+x2v6Iz2Z/H5zHvXJCC7Bwg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.82.tgz", + "integrity": "sha512-Be9Wf5RTv1w6GXlTph55K3PH3vsAh1Ax4T1FQY1UYM0QfD0yrwGdnJ8/fhqw7dEgMjd59zIbjJQC8C3msbGn5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.82.tgz", + "integrity": "sha512-LN/i8VrvxTDmEEK1c10z2cdOTkWT76LlTGtyZe5Kr1sqoSomKeExAjbilnu1+oee5lZUgS5yfZ2LNlVhCeARuw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "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", @@ -2865,6 +3095,12 @@ "@types/node": "*" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2875,6 +3111,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", @@ -4111,6 +4718,43 @@ "dev": true, "license": "MIT" }, + "node_modules/copy-webpack-plugin": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz", + "integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-parent": "^6.0.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2", + "tinyglobby": "^0.2.12" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/core-js-compat": { "version": "3.46.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", @@ -5413,6 +6057,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 +6115,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 +6516,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 +7829,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", @@ -7702,6 +8390,18 @@ "node": ">=8" } }, + "node_modules/pdfjs-dist": { + "version": "5.4.394", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.394.tgz", + "integrity": "sha512-9ariAYGqUJzx+V/1W4jHyiyCep6IZALmDzoaTLZ6VNu8q9LWi1/ukhzHgE2Xsx96AZi0mbZuK4/ttIbqSbLypg==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.81" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7954,6 +8654,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", @@ -8018,6 +8739,29 @@ "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.9.5", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz", @@ -8303,6 +9047,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 +9071,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", @@ -9392,6 +10171,54 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9432,6 +10259,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 +10384,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", @@ -9665,6 +10519,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index af8a8c7..7f20a5f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,23 +6,29 @@ "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": { "@fontsource/inter": "^5.2.8", "@tabler/icons-react": "^3.35.0", "axios": "^1.13.2", + "pdfjs-dist": "^5.4.394", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-redux": "^9.2.0", "react-router-dom": "^7.9.5" }, "devDependencies": { "@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", + "copy-webpack-plugin": "^13.0.1", "css-loader": "^7.1.2", "dotenv": "^17.2.3", "eslint": "^9.39.1", @@ -31,6 +37,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 85% rename from frontend/src/app/AppLayout.jsx rename to frontend/src/app/AppLayout.tsx index 87e1c85..c56721c 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, @@ -28,23 +28,17 @@ const AppLayout = () => { onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange, onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle, searchIncludeDescendants: documentsPreferences.searchIncludeDescendants, - onToggleSearchIncludeDescendants: documentsPreferences.toggleSearchIncludeDescendants, onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants, sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef, - handleDeskExit: documentsPreferences.handleDeskExit, }); if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { - const shouldRememberLastLocation = appStatus !== 'logged-out'; + const redirectTarget = `${location.pathname}${location.search}${location.hash || ''}`; return ( ); } 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.jsx deleted file mode 100644 index 9c9c78f..0000000 --- a/frontend/src/app/DocumentsLayout.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import Sidebar from '../sidebar/Sidebar'; -import { useSidebarContext } from '../sidebar/SidebarContext'; -import { usePanelManager } from './PanelManagerContext'; - -const DocumentsLayout = ({ sidebarProps, children }) => { - const { collapsed } = useSidebarContext(); - const { sidebarSuppressed } = usePanelManager(); - const sidebarHidden = collapsed || sidebarSuppressed; - return ( -
- {!sidebarHidden ? : null} - {children} -
- ); -}; - -export default DocumentsLayout; diff --git a/frontend/src/app/DocumentsRoute.jsx b/frontend/src/app/DocumentsRoute.jsx deleted file mode 100644 index 81f15be..0000000 --- a/frontend/src/app/DocumentsRoute.jsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useAppShell } from '../appShellContext'; -import DocumentsLayout from './DocumentsLayout'; -import { useWorkspaceSurface } from './useWorkspaceSurface'; -import PanelHeader from '../ui/PanelHeader'; -import BreadcrumbTrail from '../ui/BreadcrumbTrail'; -import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext'; -import { PanelManagerProvider, usePanelManager } from './PanelManagerContext'; - -const DocumentsRouteContent = () => { - const { - sidebarProps, - documentsTableProps, - detailPanelProps, - detailPanelOpen, - documentsViewMode, - deskWorkspaceProps, - openTagsModal, - openCorrespondentsModal, - previewWorkspaceDocument, - previewWorkspaceEntry, - previewDocumentId, - closeDocumentPreview, - ensurePreviewData, - resolveApiPath, - ensureAssetUrl, - getDocumentAsset, - notifyApiError, - } = useAppShell(); - const navigate = useNavigate(); - const { collapsed: sidebarCollapsed } = useSidebarContext(); - const { - sidebarSuppressed, - expandSidebar, - } = usePanelManager(); - - const sidebarPropsWithActions = useMemo( - () => ({ - ...sidebarProps, - onManageTags: openTagsModal, - onManageCorrespondents: openCorrespondentsModal, - }), - [sidebarProps, openTagsModal, openCorrespondentsModal], - ); - - const sidebarHidden = sidebarCollapsed || sidebarSuppressed; - - const breadcrumbs = documentsTableProps?.breadcrumbs || null; - const parentBreadcrumb = useMemo(() => { - if (!Array.isArray(breadcrumbs) || breadcrumbs.length <= 1) { - return null; - } - return breadcrumbs[breadcrumbs.length - 2]; - }, [breadcrumbs]); - - const handleNavigateParent = useCallback(() => { - if (!parentBreadcrumb) { - return; - } - const target = parentBreadcrumb.id === 'root' - ? '/documents' - : `/documents/folder/${parentBreadcrumb.id}`; - navigate(target); - }, [navigate, parentBreadcrumb]); - - const handleHeaderBreadcrumbClick = useCallback((crumb) => { - if (!crumb || !crumb.id) { - return; - } - const target = crumb.id === 'root' ? '/documents' : `/documents/folder/${crumb.id}`; - navigate(target); - }, [navigate]); - - const { surface } = useWorkspaceSurface({ - sidebarHidden, - onExpandSidebar: expandSidebar, - documentsTableProps, - detailPanelProps, - detailPanelOpen, - viewMode: documentsViewMode, - deskWorkspaceProps, - previewWorkspaceDocument, - previewWorkspaceEntry, - previewDocumentId, - ensureAssetUrl, - ensurePreviewData, - getDocumentAsset, - resolveApiPath, - notifyApiError, - closeDocumentPreview, - parentBreadcrumb, - onNavigateParent: handleNavigateParent, - }); - - useEffect(() => { - document.body.classList.add('has-main-content'); - return () => { - document.body.classList.remove('has-main-content'); - }; - }, []); - - if (!surface) { - return ( - -
-
-
- - ); - } - - const variant = surface.variant || 'documents'; - - const mainContentClass = `main-content main-content--${variant}${ - surface.detail ? ' main-content--has-detail' : '' - }`; - const bodyClass = `main-content__body main-content__body--${variant}${ - surface.detail ? ' main-content__body--has-detail' : '' - }`; - - const header = surface.header || null; - - let headerTitle = null; - if (header) { - const breadcrumbEntries = Array.isArray(header.breadcrumbs) ? header.breadcrumbs.filter(Boolean) : []; - const lastIndex = breadcrumbEntries.length - 1; - const trailEntries = breadcrumbEntries.length - ? breadcrumbEntries.map((crumb, index) => ({ - id: crumb.id ?? index, - label: crumb.name ?? crumb.label ?? crumb.title ?? '', - onClick: index < lastIndex ? () => handleHeaderBreadcrumbClick(crumb) : null, - })) - : [{ id: 'current-location', label: header.title }]; - - headerTitle = ( -

- - {header.subtitle ? ( - {header.subtitle} - ) : null} -

- ); - } - - return ( - -
- {header ? ( -
- {(header.selectionLabel || header.floatingActions) ? ( -
- {header.selectionLabel ? ( - {header.selectionLabel} - ) : null} - {header.floatingActions || null} -
- ) : null} - -
- ) : null} -
{surface.content}
- {surface.detail || null} -
-
- ); -}; - -const DocumentsRoute = () => ( - - - - - -); - -export default DocumentsRoute; diff --git a/frontend/src/app/DocumentsRoute.tsx b/frontend/src/app/DocumentsRoute.tsx new file mode 100644 index 0000000..4707435 --- /dev/null +++ b/frontend/src/app/DocumentsRoute.tsx @@ -0,0 +1,173 @@ +import React, { useCallback, useEffect, useMemo } from 'react'; +import type { ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAppShell } from '../appShellContext'; +import { + DocumentsFilterProvider, +} from '../documents/context/DocumentsFilterContext'; +import type { DocumentsFilterValue } from '../documents/context/DocumentsFilterContext'; +import { useWorkspaceSurface } from './useWorkspaceSurface'; +import { DocumentsHeaderBreadcrumb } from '../documents/panel/DocumentsPanelHeader'; +import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext'; +import { PanelManagerProvider, usePanelManager } from './PanelManagerContext'; +import Sidebar from '../sidebar/Sidebar'; + +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; + +interface DocumentsTableProps { + breadcrumbs?: DocumentsHeaderBreadcrumb[] | null; + onBreadcrumbNavigate?: (crumb: DocumentsHeaderBreadcrumb) => void; + [key: string]: unknown; +} + +interface DocumentsRouteAppShell { + sidebarProps?: Record | null; + documentsTableProps?: DocumentsTableProps | null; + detailPanelProps?: Record | null; + detailPanelOpen?: boolean; + openTagsModal?: () => void; + openCorrespondentsModal?: () => void; + previewWorkspaceDocument?: unknown; + documentLink?: unknown; + previewDocumentId?: Identifier | null; + closeDocumentPreview?: () => void; + ensurePreviewData?: EnsurePreviewData; + resolveApiPath?: ResolveApiPath; + ensureAssetUrl?: EnsureAssetUrl; + getDocumentAsset?: GetDocumentAsset; + notifyApiError?: NotifyApiError; + documentsFilter: DocumentsFilterValue; +} + +const DocumentsRouteContent: React.FC = () => { + const { + sidebarProps, + documentsTableProps, + detailPanelProps, + detailPanelOpen, + openTagsModal, + openCorrespondentsModal, + previewWorkspaceDocument, + documentLink, + previewDocumentId, + closeDocumentPreview, + ensurePreviewData, + resolveApiPath, + ensureAssetUrl, + getDocumentAsset, + notifyApiError, + documentsFilter, + } = useAppShell() as unknown as DocumentsRouteAppShell; + const navigate = useNavigate(); + const { collapsed: sidebarCollapsed } = useSidebarContext(); + const { + sidebarSuppressed, + expandSidebar, + } = usePanelManager(); + + const safeSidebarProps = useMemo>( + () => (sidebarProps && Object(sidebarProps) === sidebarProps ? sidebarProps : {}), + [sidebarProps], + ); + + const sidebarPropsWithActions = useMemo( + () => ({ + ...safeSidebarProps, + onManageTags: openTagsModal, + onManageCorrespondents: openCorrespondentsModal, + }), + [safeSidebarProps, openTagsModal, openCorrespondentsModal], + ); + + const sidebarHidden = sidebarCollapsed || sidebarSuppressed; + + const handleHeaderBreadcrumbClick = useCallback((crumb: DocumentsHeaderBreadcrumb) => { + if (!crumb || !crumb.id) { + return; + } + const target = crumb.id === 'root' ? '/documents' : `/documents/folder/${crumb.id}`; + navigate(target); + }, [navigate]); + + const documentsTablePropsWithNav = useMemo(() => ( + documentsTableProps + ? { ...documentsTableProps, onBreadcrumbNavigate: handleHeaderBreadcrumbClick } + : null + ), [documentsTableProps, handleHeaderBreadcrumbClick]); + + const { surface } = useWorkspaceSurface({ + sidebarHidden, + onExpandSidebar: expandSidebar, + documentsTableProps: documentsTablePropsWithNav, + detailPanelProps, + detailPanelOpen, + previewWorkspaceDocument, + documentLink, + previewDocumentId, + ensureAssetUrl, + ensurePreviewData, + getDocumentAsset, + resolveApiPath, + notifyApiError, + closeDocumentPreview, + }); + + useEffect(() => { + document.body.classList.add('has-main-content'); + return () => { + document.body.classList.remove('has-main-content'); + }; + }, []); + + const renderSurface = () => { + if (!surface) { + return ( +
+ {!sidebarHidden ? : null} +
+
+
+
+ ); + } + + const surfaceDetail = (surface as { detail?: ReactNode }).detail || null; + return ( +
+ {!sidebarHidden ? : null} +
+
{surface.content}
+ {surfaceDetail} +
+
+ ); + }; + + const content = renderSurface(); + return ( + + {content} + + ); +}; + +const DocumentsRoute: React.FC = () => ( + + + + + +); + +export default DocumentsRoute; 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 87% rename from frontend/src/app/LoginRoute.jsx rename to frontend/src/app/LoginRoute.tsx index fcf7370..df75310 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); @@ -37,26 +57,24 @@ const LoginRoute = () => { let combined = extract(location.search); - if (typeof window !== 'undefined') { - const hash = window.location.hash || ''; - const queryIndex = hash.indexOf('?'); - if (queryIndex !== -1) { - const hashQuery = hash.slice(queryIndex + 1); - const hashParams = extract(`?${hashQuery}`); - combined = { - token: combined.token || hashParams.token, - username: combined.username || hashParams.username, - preferredTenantId: combined.preferredTenantId || hashParams.preferredTenantId, - }; - } - if (!combined.token) { - const searchParams = extract(window.location.search); - combined = { - token: combined.token || searchParams.token, - username: combined.username || searchParams.username, - preferredTenantId: combined.preferredTenantId || searchParams.preferredTenantId, - }; - } + const hash = window.location.hash || ''; + const queryIndex = hash.indexOf('?'); + if (queryIndex !== -1) { + const hashQuery = hash.slice(queryIndex + 1); + const hashParams = extract(`?${hashQuery}`); + combined = { + token: combined.token || hashParams.token, + username: combined.username || hashParams.username, + preferredTenantId: combined.preferredTenantId || hashParams.preferredTenantId, + }; + } + if (!combined.token) { + const searchParams = extract(window.location.search); + combined = { + token: combined.token || searchParams.token, + username: combined.username || searchParams.username, + preferredTenantId: combined.preferredTenantId || searchParams.preferredTenantId, + }; } return combined; @@ -96,10 +114,6 @@ const LoginRoute = () => { ); const clearMagicParamsFromUrl = useCallback(() => { - if (typeof window === 'undefined') { - return; - } - const removableKeys = ['magic_token', 'username', 'preferred_tenant_id']; const currentSearch = new URLSearchParams(window.location.search); let searchChanged = false; @@ -184,7 +198,7 @@ const LoginRoute = () => { const handlePasskeyLogin = useCallback( async (rawUsername) => { - const username = typeof rawUsername === 'string' ? rawUsername.trim() : ''; + const username = rawUsername?.trim?.() || ''; if (!username) { setStatusMessage('Enter your username before using a passkey.', 'error'); return; @@ -207,13 +221,18 @@ const LoginRoute = () => { const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions }); setStatusMessage('Confirm the passkey prompt to continue.', 'info'); - const assertion = await navigator.credentials.get({ publicKey }); + const assertion = await navigator.credentials.get({ publicKey }) as PublicKeyCredential | null; if (!assertion) { setStatusMessage('Passkey login cancelled.', 'info'); return; } + if (!(assertion instanceof PublicKeyCredential)) { + setStatusMessage('Unexpected credential response.', 'error'); + return; + } + const serialized = serializeAuthenticationCredential(assertion); const finishPayload = { challengeId, @@ -289,7 +308,11 @@ const LoginRoute = () => { setStatusMessage('Signing you in…', 'info'); try { - const payload = { + const payload: { + magic_token: string; + username?: string; + preferred_tenant_id?: string | number; + } = { magic_token: magicToken, }; if (magicUsername) { @@ -381,7 +404,7 @@ const LoginRoute = () => { const handleSignup = useCallback( async (rawUsername) => { - const username = typeof rawUsername === 'string' ? rawUsername.trim() : ''; + const username = rawUsername?.trim?.() || ''; if (!username) { setStatusMessage('Choose a username to create your account.', 'error'); return; @@ -405,13 +428,18 @@ const LoginRoute = () => { const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions }); setStatusMessage('Confirm the passkey prompt to finish creating your account.', 'info'); - const credential = await navigator.credentials.create({ publicKey }); + const credential = await navigator.credentials.create({ publicKey }) as PublicKeyCredential | null; if (!credential) { setStatusMessage('Signup cancelled.', 'info'); return; } + if (!(credential instanceof PublicKeyCredential)) { + setStatusMessage('Unexpected credential response.', 'error'); + return; + } + const serialized = serializeRegistrationCredential(credential); const finishPayload = { signup_token: signupToken, @@ -459,8 +487,8 @@ const LoginRoute = () => { ); const redirectTarget = useMemo(() => { - const target = location.state?.from; - if (typeof target === 'string' && target.startsWith('/')) { + const target = String(location.state?.from ?? ''); + if (target.startsWith('/')) { return target; } return '/documents'; diff --git a/frontend/src/app/PanelManagerContext.js b/frontend/src/app/PanelManagerContext.tsx similarity index 52% rename from frontend/src/app/PanelManagerContext.js rename to frontend/src/app/PanelManagerContext.tsx index 91fa6da..714a640 100644 --- a/frontend/src/app/PanelManagerContext.js +++ b/frontend/src/app/PanelManagerContext.tsx @@ -1,4 +1,5 @@ import React, { + ReactNode, createContext, useCallback, useContext, @@ -7,38 +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 = { - sidebar: { minRatio: 1 / 6, maxRatio: 1 / 4 }, - detail: { minRatio: 1 / 4, maxRatio: 3 / 4 }, +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 STORAGE_KEYS = { +const PanelManagerContext = createContext(null); + +const PANEL_LIMITS: Record = { + sidebar: { + maxRatio: 1 / 3, + minPx: 280, + }, + detail: { + maxRatio: 2 / 3, + minPx: 320, + }, +}; + +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 minLimit = Math.max(0, Math.round(viewport * limits.minRatio)); - const rawMax = Math.max(minLimit, Math.round(viewport * limits.maxRatio)); - const maxAllowed = Math.min(rawMax, viewport - 160); - const targetMax = Math.max(minLimit, maxAllowed); - return Math.min(Math.max(numeric, minLimit), targetMax); + const minLimit = Math.max(0, limits.minPx); + const ratioMax = Math.round(viewport * limits.maxRatio); + 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; } @@ -46,22 +94,27 @@ 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, active: boolean): void => { if (!Number.isFinite(width)) { return; } const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width'; - document?.documentElement?.style.setProperty(varName, `${width}px`); + const resolvedValue = panel === 'detail' && !active ? '0px' : `${width}px`; + document.documentElement.style.setProperty(varName, resolvedValue); }; -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)); @@ -71,6 +124,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; @@ -79,23 +133,20 @@ export const PanelManagerProvider = ({ children }) => { useEffect(() => { panelWidthsRef.current.sidebar = sidebarWidth; - applyPanelWidthToRoot('sidebar', sidebarWidth); + applyPanelWidthToRoot('sidebar', sidebarWidth, true); }, [sidebarWidth]); useEffect(() => { panelWidthsRef.current.detail = detailWidth; - applyPanelWidthToRoot('detail', detailWidth); - }, [detailWidth]); + applyPanelWidthToRoot('detail', detailWidth, detailPanelOpen); + }, [detailWidth, detailPanelOpen]); - 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; @@ -103,6 +154,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') { @@ -112,34 +164,20 @@ export const PanelManagerProvider = ({ children }) => { } } - if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && detailPanelOpen) { + if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && effectiveDetailOpen) { closeDetailPanel(); } - - const normalizedAction = action === 'resized' - ? `${panel} resized to ${value}px` - : `${panel} ${action}`; - console.log(normalizedAction, { - sidebar: sidebarWidth > 0 ? `${sidebarWidth}px` : 'closed', - detail: `${detailWidth}px`, - freeSpace, - viewportWidth, - freeRatio, - 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]; @@ -150,36 +188,79 @@ export const PanelManagerProvider = ({ children }) => { setDetailWidthState((prev) => (prev === clamped ? prev : clamped)); } 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); @@ -237,7 +318,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, @@ -267,7 +351,7 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true useEffect(() => () => teardownListeners(), [teardownListeners]); const handlePointerDown = useCallback( - (event) => { + (event: ReactPointerEvent) => { if (!enabled || !panelRef?.current) { return; } @@ -284,18 +368,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.jsx deleted file mode 100644 index 40af83c..0000000 --- a/frontend/src/app/UploadQueueOverlay.jsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { useMemo, useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - CloseIcon, - LoaderIcon, - CheckIcon, - InfoIcon, - WarningIcon, - BottombarCollapseIcon, - BottombarExpandIcon, - ClearAllIcon, -} from '../ui/icons'; -import PanelHeader from '../ui/PanelHeader'; - -const STATUS_META = { - pending: { - label: 'Queued', - tone: 'muted', - icon: , - }, - uploading: { - label: 'Uploading', - tone: 'accent', - icon: , - }, - success: { - label: 'Uploaded', - tone: 'success', - icon: , - }, - duplicate: { - label: 'Duplicate', - tone: 'info', - icon: , - }, - error: { - label: 'Failed', - tone: 'danger', - icon: , - }, -}; - -const UploadQueueOverlay = ({ queue = [], onClearQueue }) => { - const navigate = useNavigate(); - const [collapsed, setCollapsed] = useState(false); - const [dismissed, setDismissed] = useState(false); - - useEffect(() => { - if (queue.length > 0) { - setDismissed(false); - } - }, [queue.length]); - - const summary = useMemo(() => { - if (!queue.length) { - return 'No uploads'; - } - const uploadingCount = queue.filter((item) => item.status === 'uploading').length; - const pendingCount = queue.filter((item) => item.status === 'pending').length; - const errorCount = queue.filter((item) => item.status === 'error').length; - if (uploadingCount > 0 || pendingCount > 0) { - return `${uploadingCount} uploading · ${pendingCount} queued`; - } - if (errorCount > 0) { - return `${errorCount} failed · ${queue.length} total`; - } - return `${queue.length} completed`; - }, [queue]); - - const hasActiveUploads = queue.some((item) => item.status === 'uploading' || item.status === 'pending'); - - const handleClearQueue = () => { - if (!onClearQueue || hasActiveUploads) { - return; - } - onClearQueue(); - }; - - if (!queue.length || dismissed) { - return null; - } - - return ( -
- - Uploads - {summary} - - )} - actions={( -
- - - -
- )} - /> - {!collapsed ? ( -
    - {[...queue] - .slice() - .reverse() - .map((item) => { - const meta = STATUS_META[item.status] || STATUS_META.pending; - const fileLabel = item.name; - const duplicateLabel = item.status === 'duplicate' ? item.document?.title || null : null; - const documentId = item.document?.id || item.conflictDocumentId || null; - const hasLink = Boolean(documentId); - const handleNavigate = () => { - if (!documentId) { - return; - } - navigate(`/documents/${documentId}`); - }; - return ( -
  • - - {meta.icon} - -
    - {item.status === 'success' && hasLink ? ( - - ) : ( -
    - {fileLabel} -
    - )} -
    - {item.status === 'duplicate' && duplicateLabel ? ( - - Duplicate of{' '} - - - ) : item.status === 'error' && item.error ? ( - - {item.error} - - ) : ( - <> - {meta.label} - {documentId ? ( - - ( - - ) - - ) : null} - - )} -
    -
    -
  • - ); - })} -
- ) : null} -
- ); -}; - -export default UploadQueueOverlay; diff --git a/frontend/src/app/UploadQueueOverlay.tsx b/frontend/src/app/UploadQueueOverlay.tsx new file mode 100644 index 0000000..98052e1 --- /dev/null +++ b/frontend/src/app/UploadQueueOverlay.tsx @@ -0,0 +1,208 @@ +import { useMemo, useState, useEffect } from 'react'; +import type { JSX } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + CloseIcon, + LoaderIcon, + CheckIcon, + InfoIcon, + WarningIcon, + BottombarCollapseIcon, + BottombarExpandIcon, +} from '../ui/icons'; +import PanelHeader from '../ui/PanelHeader'; + +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', + icon: , + }, + uploading: { + label: 'Uploading', + tone: 'accent', + icon: , + }, + success: { + label: 'Uploaded', + tone: 'success', + icon: , + }, + duplicate: { + label: 'Duplicate', + tone: 'info', + icon: , + }, + error: { + label: 'Failed', + tone: 'danger', + icon: , + }, +}; + +const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProps): JSX.Element | null => { + const navigate = useNavigate(); + const [collapsed, setCollapsed] = useState(false); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (queue.length > 0) { + setDismissed(false); + } + }, [queue.length]); + + const summary = useMemo(() => { + if (!queue.length) { + return 'No uploads'; + } + const uploadingCount = queue.filter((item) => item.status === 'uploading').length; + const pendingCount = queue.filter((item) => item.status === 'pending').length; + const errorCount = queue.filter((item) => item.status === 'error').length; + if (uploadingCount > 0 || pendingCount > 0) { + return `${uploadingCount} uploading · ${pendingCount} queued`; + } + if (errorCount > 0) { + return `${errorCount} failed · ${queue.length} total`; + } + return `${queue.length} completed`; + }, [queue]); + + const hasActiveUploads = queue.some((item) => item.status === 'uploading' || item.status === 'pending'); + + const handleDismissOverlay = () => { + if (!queue.length) { + setDismissed(true); + return; + } + + if (hasActiveUploads) { + const confirmed = window.confirm('Uploads are still running. Clear the queue and hide the overlay?'); + if (!confirmed) { + return; + } + } + + onClearQueue?.(); + setDismissed(true); + }; + + if (!queue.length || dismissed) { + return null; + } + + return ( +
+ + Uploads + {summary} + + )} + actions={( +
+ + +
+ )} + /> + {!collapsed ? ( +
+
    + {[...queue] + .slice() + .reverse() + .map((item) => { + const meta = STATUS_META[item.status] || STATUS_META.pending; + const fileLabel = item.name; + const documentTitle = item.document?.title || null; + const duplicateLabel = item.status === 'duplicate' ? documentTitle : null; + const documentId = item.document?.id || item.conflictDocumentId || null; + const hasLink = Boolean(documentId); + const handleNavigate = () => { + if (!documentId) { + return; + } + navigate(`/documents/${documentId}`); + }; + return ( +
  • + + {meta.icon} + +
    + {item.status === 'success' && hasLink ? ( + + ) : ( +
    + {fileLabel} +
    + )} +
    + {item.status === 'duplicate' && duplicateLabel ? ( + + Duplicate of{' '} + + + ) : item.status === 'error' && item.error ? ( + + {item.error} + + ) : ( + {meta.label} + )} +
    +
    +
  • + ); + })} +
+
+ ) : null} +
+ ); +}; + +export default UploadQueueOverlay; diff --git a/frontend/src/app/WorkspaceSelectionContext.tsx b/frontend/src/app/WorkspaceSelectionContext.tsx new file mode 100644 index 0000000..47fc5ad --- /dev/null +++ b/frontend/src/app/WorkspaceSelectionContext.tsx @@ -0,0 +1,27 @@ +import React, { createContext, useContext } from 'react'; +import type useWorkspaceSelection from './useWorkspaceSelection'; + +export type WorkspaceSelectionValue = ReturnType; + +const WorkspaceSelectionContext = createContext(null); + +interface WorkspaceSelectionProviderProps { + value: WorkspaceSelectionValue; + children: React.ReactNode; +} + +export const WorkspaceSelectionProvider: React.FC = ({ value, children }) => ( + + {children} + +); + +export const useWorkspaceSelectionContext = () => { + const context = useContext(WorkspaceSelectionContext); + if (!context) { + throw new Error('useWorkspaceSelectionContext must be used within a WorkspaceSelectionProvider'); + } + return context; +}; + +export default WorkspaceSelectionContext; diff --git a/frontend/src/app/appLayoutUtils.js b/frontend/src/app/appLayoutUtils.ts similarity index 74% rename from frontend/src/app/appLayoutUtils.js rename to frontend/src/app/appLayoutUtils.ts index 1896b6a..784b107 100644 --- a/frontend/src/app/appLayoutUtils.js +++ b/frontend/src/app/appLayoutUtils.ts @@ -1,4 +1,4 @@ -import { createAssetView } from '../asset_manager'; +import { createAssetView, resolveAssetExpiresAt } from '../asset_manager'; export const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early export const DEFAULT_FOLDER_NAME = 'Documents'; @@ -16,13 +16,15 @@ export const resolveApiPath = (path = '') => path; const makeRowKey = (type, id) => id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`; -const getRowType = (key) => (typeof key === 'string' ? key.split(ROW_KEY_SEPARATOR, 1)[0] : ''); +const normalizeRowKey = (key: string | number | null) => String(key ?? ''); + +const getRowType = (key) => normalizeRowKey(key).split(ROW_KEY_SEPARATOR, 1)[0] ?? ''; export const getRowId = (key) => { - if (typeof key !== 'string') return ''; - const separatorIndex = key.indexOf(ROW_KEY_SEPARATOR); - if (separatorIndex === -1) return key; - return key.slice(separatorIndex + 1); + const normalized = normalizeRowKey(key); + const separatorIndex = normalized.indexOf(ROW_KEY_SEPARATOR); + if (separatorIndex === -1) return normalized; + return normalized.slice(separatorIndex + 1); }; export const isDocumentRowKey = (key) => getRowType(key) === DOCUMENT_ROW_PREFIX; @@ -43,34 +45,17 @@ const isAssetEquivalent = (lhs, rhs) => { const rhsView = createAssetView(rhs); const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata; const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata; - const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null; - const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null; - const lhsObjects = lhsView.getObjects(); - const rhsObjects = rhsView.getObjects(); - const objectsComparable = - lhsObjects.length === rhsObjects.length - && lhsObjects.every((entry, index) => { - const other = rhsObjects[index]; - if (!other) return false; - if (entry.ordinal !== other.ordinal) return false; - if (entry.url && other.url && entry.url === other.url) { - return true; - } - if (!entry.url && !other.url) { - return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null); - } - return entry.url === other.url; - }); + const lhsExpiresAt = resolveAssetExpiresAt(lhs); + const rhsExpiresAt = resolveAssetExpiresAt(rhs); return ( lhs.id === rhs.id && lhs.url === rhs.url + && lhsExpiresAt === rhsExpiresAt && lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width && lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height && lhs.mime_type === rhs.mime_type && lhs.asset_type === rhs.asset_type && lhs.updated_at === rhs.updated_at - && lhsCardinality === rhsCardinality - && objectsComparable ); }; diff --git a/frontend/src/app/appState.js b/frontend/src/app/appState.tsx similarity index 74% rename from frontend/src/app/appState.js rename to frontend/src/app/appState.tsx index 8a63e46..e728128 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 = typeof window !== 'undefined' ? window.sessionStorage : undefined; +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 = window.sessionStorage; 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 51% rename from frontend/src/app/useDocumentPreview.js rename to frontend/src/app/useDocumentPreview.ts index 36f0671..aa09d21 100644 --- a/frontend/src/app/useDocumentPreview.js +++ b/frontend/src/app/useDocumentPreview.ts @@ -1,11 +1,71 @@ 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 DocumentLink = { + url?: string; + contentType?: string | null; + filename?: string | null; + expiresAt?: number; +}; + +interface ApiClient { + get: (path: string) => Promise<{ data: T }>; +} + +type NavigateHandler = (path: string, options?: { replace?: boolean }) => void; + +interface UseDocumentPreviewArgs { + routeDocumentId?: DocumentId | null; + documentsManager: { + getById: (id: DocumentId) => DocumentLike | null; + ensure: (id: DocumentId) => Promise; + getMany: (ids: DocumentId[]) => DocumentLike[]; + subscribe: (listener: () => void) => () => void; + ingest: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean }; + }; + selectedFolder?: FolderId | null; + 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 { + documentLinks: Map; + ensureDownloadUrl: (documentId: DocumentId, options?: { force?: boolean }) => Promise; + ensurePreviewData: (documentId: DocumentId) => Promise; + openDocumentPreview: (documentId: DocumentId, options?: { replace?: boolean }) => void; + closeDocumentPreview: (folderId?: FolderId) => void; + resetPreviewState: () => void; + removeDocumentLinks: (ids: DocumentId[]) => void; +} const useDocumentPreview = ({ routeDocumentId, - documents, - searchResults, + documentsManager, selectedFolder, - assetManager, api, resolveApiPath, notifyApiError, @@ -14,23 +74,22 @@ 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 [documentLinks, setDocumentLinks] = useState>(() => new Map()); + const previewInflightRef = useRef>>(new Map()); + const previewReturnPathRef = useRef(null); const resetPreviewState = useCallback(() => { - setPreviewEntries(() => new Map()); + setDocumentLinks(() => new Map()); previewInflightRef.current = new Map(); previewReturnPathRef.current = null; }, []); - const removePreviewEntries = useCallback((ids) => { + const removeDocumentLinks = useCallback((ids: DocumentId[]) => { if (!Array.isArray(ids) || ids.length === 0) { return; } - setPreviewEntries((prev) => { + setDocumentLinks((prev) => { if (!prev.size) { return prev; } @@ -46,66 +105,37 @@ const useDocumentPreview = ({ }); }, []); - const cachePreviewDocument = useCallback((doc) => { - if (!doc?.id) { - return; - } - setPreviewDocuments((prev) => { - const existing = prev.get(doc.id); - if (existing === doc) { - return prev; - } - const next = new Map(prev); - next.set(doc.id, doc); - return next; - }); - }, []); - - const removeCachedPreviewDocument = useCallback((documentId) => { - if (!documentId) { - return; - } - setPreviewDocuments((prev) => { - if (!prev.has(documentId)) { - return prev; - } - const next = new Map(prev); - next.delete(documentId); - return next; - }); - }, []); - - const ensurePreviewUrl = useCallback( - async (documentId, { force = false } = {}) => { + const ensureDownloadUrl = useCallback( + async (documentId: DocumentId, { force = false }: { force?: boolean } = {}): Promise => { if (!documentId) return null; - const existing = previewEntries.get(documentId) || null; + const existing = documentLinks.get(documentId) || null; const now = Date.now(); - const expiresAt = typeof existing?.expiresAt === 'number' ? existing.expiresAt : null; + const expiresAt = Number.isFinite(existing?.expiresAt) ? Number(existing?.expiresAt) : null; if (!force && existing && (!expiresAt || expiresAt > now)) { return existing; } 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: DocumentLink = { url: href, contentType: docResponse.data?.document?.current_version?.version?.content_type || null, filename: docResponse.data?.document?.filename, expiresAt: Date.now() + 5 * 60 * 1000, }; - setPreviewEntries((prev) => { + setDocumentLinks((prev) => { const next = new Map(prev); next.set(documentId, entry); return next; @@ -122,39 +152,29 @@ const useDocumentPreview = ({ previewInflightRef.current.set(documentId, request); return request; }, - [previewEntries, api, resolveApiPath, notifyApiError, setPreviewEntries], + [documentLinks, api, resolveApiPath, notifyApiError], ); const ensurePreviewData = useCallback( - async (documentId) => { + async (documentId: DocumentId): Promise => { if (!documentId) return null; - const findInCache = () => { - const pool = searchResults ?? documents; - return pool.find((item) => item.id === documentId) || null; - }; + const findInCache = () => documentsManager.getById(documentId); let doc = findInCache(); + if (!doc) { + doc = await documentsManager.ensure(documentId); + } + 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 = (data as { document?: DocumentLike })?.document || data; + const { canonical } = documentsManager.ingest([fetched as unknown]); + doc = (canonical[0] as DocumentLike | undefined) || null; if (!doc) { throw new Error('Document metadata unavailable.'); } - - const existsInDocuments = documents.some((item) => item.id === doc.id); - const existsInSearch = Array.isArray(searchResults) - ? searchResults.some((item) => item.id === doc.id) - : false; - - if (existsInDocuments || existsInSearch) { - removeCachedPreviewDocument(doc.id); - } else { - cachePreviewDocument(doc); - } } if (!previewReturnPathRef.current) { @@ -163,26 +183,22 @@ const useDocumentPreview = ({ fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`; } - await ensurePreviewUrl(documentId, { force: false }); + await ensureDownloadUrl(documentId, { force: false }); setActivePreviewId(documentId); return doc; }, [ - searchResults, - documents, - assetManager, - ensurePreviewUrl, + documentsManager, + ensureDownloadUrl, setActivePreviewId, api, - cachePreviewDocument, - removeCachedPreviewDocument, ], ); const openDocumentPreview = useCallback( - (documentId, { replace = false } = {}) => { + (documentId: DocumentId, { replace = false }: { replace?: boolean } = {}) => { if (!documentId) return; - detailPanelControlRef.current.close(); + detailPanelControlRef.current?.close?.(); previewReturnPathRef.current = `${locationPathname}${locationSearch}`; navigate(`/documents/${documentId}`, { replace }); }, @@ -190,7 +206,7 @@ const useDocumentPreview = ({ ); const closeDocumentPreview = useCallback( - (folderId = null) => { + (folderId?: FolderId) => { const fallbackPath = previewReturnPathRef.current; previewReturnPathRef.current = null; @@ -206,21 +222,6 @@ const useDocumentPreview = ({ [navigate, selectedFolder], ); - useEffect(() => { - if (!routeDocumentId) { - return undefined; - } - - const handleKeyDown = (event) => { - if (event.key === 'Escape') { - closeDocumentPreview(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [routeDocumentId, closeDocumentPreview]); - useEffect(() => { if (!routeDocumentId) { return undefined; @@ -241,39 +242,14 @@ const useDocumentPreview = ({ }; }, [routeDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]); - useEffect(() => { - setPreviewDocuments((prev) => { - if (!prev.size) { - return prev; - } - const next = new Map(prev); - let changed = false; - const prune = (list) => { - if (!Array.isArray(list)) { - return; - } - list.forEach((doc) => { - if (doc?.id && next.has(doc.id)) { - next.delete(doc.id); - changed = true; - } - }); - }; - prune(documents); - prune(searchResults); - return changed ? next : prev; - }); - }, [documents, searchResults]); - return { - previewEntries, - previewDocuments, - ensurePreviewUrl, + documentLinks, + ensureDownloadUrl, ensurePreviewData, openDocumentPreview, closeDocumentPreview, resetPreviewState, - removePreviewEntries, + removeDocumentLinks, }; }; 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..36a4077 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) => RowKey | null; + resolveFolderRowKey: (id: DocumentId | null) => RowKey | null; + isDocumentRowKey: (key?: RowKey | null) => boolean; + isFolderRowKey: (key?: RowKey | null) => boolean; + getRowId: (key?: RowKey | null) => DocumentId | null; + 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 = 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, 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 c02468f..76fcf13 100644 --- a/frontend/src/app/useDocumentsPreferences.js +++ b/frontend/src/app/useDocumentsPreferences.ts @@ -10,10 +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) => { - if (typeof window === 'undefined') { - return null; - } +const readSessionStorage = (key: string): string | null => { try { return window.sessionStorage.getItem(key); } catch (error) { @@ -22,10 +19,7 @@ const readSessionStorage = (key) => { } }; -const writeSessionStorage = (key, value) => { - if (typeof window === 'undefined') { - return; - } +const writeSessionStorage = (key: string, value: string): void => { try { window.sessionStorage.setItem(key, value); } catch (error) { @@ -34,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; @@ -42,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) { @@ -81,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 50% rename from frontend/src/app/useDocumentsSearch.js rename to frontend/src/app/useDocumentsSearch.ts index e5e0447..9329b99 100644 --- a/frontend/src/app/useDocumentsSearch.js +++ b/frontend/src/app/useDocumentsSearch.ts @@ -1,33 +1,91 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - TAG_FILTER_UNTAGGED, - resolveDocumentRowKey, - isDocumentRowKey, -} from './appLayoutUtils'; +import type { Dispatch, SetStateAction } from 'react'; +import { TAG_FILTER_UNTAGGED } from './appLayoutUtils'; + +type Identifier = string | number; + +type DocumentLike = { id?: Identifier } & Record; + +type ApiClient = { + get: (url: string, config?: { params?: Record }) => Promise<{ data: T }>; +}; + +interface UseDocumentsSearchArgs { + api: ApiClient; + token?: string | null; + selectedFolder?: Identifier | 'root' | null; + navigate?: (path: string, options?: { replace?: boolean }) => void; + locationPathname?: string; + isDocumentsRoute?: boolean; + searchIncludeDescendants?: boolean; + documentsSortField?: string; + documentsSortDirection?: string; + notifyApiError: (error: unknown, message: string) => void; + setLoading: (state: boolean) => void; + setSearchIncludeDescendants: (value: boolean) => void; + documentsManager: { + ingest: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean }; + }; +} + +interface UseDocumentsSearchResult { + searchQuery: string; + setSearchQuery: Dispatch>; + searchResultIds: Identifier[] | null; + setSearchResultIds: 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; + refetchSearchResults: () => void; + documentsFilterValue: { + query: string; + searchResultIds: Identifier[] | null; + searchLoading: boolean; + includeDescendants: boolean; + activeTagIds: Identifier[]; + activeCorrespondentIds: Identifier[]; + isActive: boolean; + setQuery: (value: string) => void; + submit: () => void; + clear: () => void; + toggleTag: (tagId: Identifier) => void; + toggleCorrespondent: (correspondentId?: Identifier | null) => void; + toggleIncludeDescendants: () => void; + }; +} const useDocumentsSearch = ({ api, - assetManager, token, selectedFolder, navigate, locationPathname, isDocumentsRoute, - selectionHelpers, searchIncludeDescendants, documentsSortField, documentsSortDirection, 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); + documentsManager, +}: UseDocumentsSearchArgs): UseDocumentsSearchResult => { + const [searchQuery, setSearchQuery] = useState(''); + const [activeTagFilters, setActiveTagFilters] = useState([]); + const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); + const [searchResultIds, setSearchResultIds] = useState(null); + const [searchLoading, setSearchLoading] = useState(false); + const [searchTrigger, setSearchTrigger] = useState(0); - const toggleTagFilter = useCallback((tagId) => { + const toggleTagFilter = useCallback((tagId: Identifier) => { if (!tagId) return; setActiveTagFilters((previous) => { if (tagId === TAG_FILTER_UNTAGGED) { @@ -41,7 +99,7 @@ const useDocumentsSearch = ({ }); }, []); - const toggleCorrespondentFilter = useCallback((correspondentId) => { + const toggleCorrespondentFilter = useCallback((correspondentId?: Identifier | null) => { setActiveCorrespondentFilters((previous) => { if (!correspondentId) { return []; @@ -64,11 +122,12 @@ const useDocumentsSearch = ({ setActiveCorrespondentFilters([]); setSearchLoading(false); setSearchIncludeDescendants(true); + setSearchResultIds(null); }, [ setSearchIncludeDescendants, ]); - const handleSearchChange = useCallback((value) => { + const handleSearchChange = useCallback((value: string) => { setSearchQuery(value); }, []); @@ -81,11 +140,48 @@ const useDocumentsSearch = ({ } }, [navigate, selectedFolder, isDocumentsRoute, locationPathname]); + const documentsFilterValue = useMemo( + () => ({ + query: searchQuery, + searchResultIds, + searchLoading, + includeDescendants: Boolean(searchIncludeDescendants), + activeTagIds: activeTagFilters, + activeCorrespondentIds: activeCorrespondentFilters, + isActive: isFilterActive, + setQuery: handleSearchChange, + submit: handleSearchSubmit, + clear: clearFilters, + toggleTag: toggleTagFilter, + toggleCorrespondent: toggleCorrespondentFilter, + toggleIncludeDescendants: () => setSearchIncludeDescendants(!searchIncludeDescendants), + }), + [ + searchQuery, + searchResultIds, + searchLoading, + searchIncludeDescendants, + activeTagFilters, + activeCorrespondentFilters, + isFilterActive, + handleSearchChange, + handleSearchSubmit, + clearFilters, + toggleTagFilter, + toggleCorrespondentFilter, + setSearchIncludeDescendants, + ], + ); + + const refetchSearchResults = useCallback(() => { + setSearchTrigger(Date.now()); + }, []); + useEffect(() => { if (!token) return undefined; if (!isFilterActive) { - setSearchResults(null); + setSearchResultIds(null); setSearchLoading(false); return undefined; } @@ -98,7 +194,7 @@ const useDocumentsSearch = ({ started = true; setLoading(true); try { - const params = {}; + const params: Record = {}; const trimmedQuery = searchQuery.trim(); if (trimmedQuery.length) { params.query = trimmedQuery; @@ -131,57 +227,24 @@ 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 || []); - setSearchResults(results); + const results = Array.isArray(data) ? data : []; + const { canonical } = documentsManager.ingest(results); + const ids = canonical + .map((doc) => (doc?.id ?? null) as Identifier | null) + .filter((id): id is Identifier => id != null); + setSearchResultIds(ids); - if (!results.length) { + if (!ids.length) { setSearchLoading(false); - selectionHelpers.setSelectedEntries([]); - selectionHelpers.setFocusedDocumentId(null); - selectionHelpers.selectionOrderRef.current = []; - selectionHelpers.setSelectionOrder([]); - selectionHelpers.selectionAnchorRef.current = null; return; } - - const resultKeys = results - .map((doc) => resolveDocumentRowKey(doc.id)) - .filter(Boolean); - - let targetKey = null; - let nextSelectionKeys = []; - - selectionHelpers.setSelectedEntries((previous) => { - const previousDocKeys = previous.filter(isDocumentRowKey); - const filtered = previousDocKeys.filter((key) => resultKeys.includes(key)); - if (filtered.length) { - targetKey = filtered[filtered.length - 1]; - nextSelectionKeys = filtered; - return filtered; - } - targetKey = null; - nextSelectionKeys = []; - return []; - }); - - selectionHelpers.selectionOrderRef.current = nextSelectionKeys; - selectionHelpers.setSelectionOrder(nextSelectionKeys); - - selectionHelpers.setFocusedDocumentId((previous) => { - if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) { - return previous; - } - return null; - }); - - selectionHelpers.selectionAnchorRef.current = targetKey; } catch (error) { if (cancelled) return; notifyApiError(error, 'Search failed. Please try again.'); - setSearchResults(null); + setSearchResultIds(null); } finally { if (!cancelled && started) { setLoading(false); @@ -210,16 +273,16 @@ const useDocumentsSearch = ({ documentsSortDirection, selectedFolder, notifyApiError, - assetManager, - selectionHelpers, setLoading, + documentsManager, + searchTrigger, ]); return { searchQuery, setSearchQuery, - searchResults, - setSearchResults, + searchResultIds, + setSearchResultIds, searchLoading, setSearchLoading, activeTagFilters, @@ -232,6 +295,8 @@ const useDocumentsSearch = ({ clearFilters, handleSearchChange, handleSearchSubmit, + refetchSearchResults, + documentsFilterValue, }; }; diff --git a/frontend/src/app/useManagementModals.js b/frontend/src/app/useManagementModals.tsx similarity index 55% rename from frontend/src/app/useManagementModals.js rename to frontend/src/app/useManagementModals.tsx index 76a26ad..159dfda 100644 --- a/frontend/src/app/useManagementModals.js +++ b/frontend/src/app/useManagementModals.tsx @@ -1,26 +1,63 @@ -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 CorrespondentsPanel, { CorrespondentsPanelProps } from '../correspondents/CorrespondentsPanel'; import PanelHeader from '../ui/PanelHeader'; +import { CloseIcon } from '../ui/icons'; 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 +74,7 @@ export const useManagementModals = ({ if (!activeModal) { return undefined; } - const handleKeyDown = (event) => { + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); setActiveModal(null); @@ -70,8 +107,8 @@ export const useManagementModals = ({ titleTag="h3" titleProps={{ id: 'tags-modal-title' }} actions={( - )} /> @@ -99,6 +136,36 @@ export const useManagementModals = ({ tags, ]); + const handleCorrespondentCreateSafe = useCallback( + async (payload) => { + if (!onCorrespondentCreate) { + return undefined; + } + return onCorrespondentCreate(payload) ?? undefined; + }, + [onCorrespondentCreate], + ); + + const handleCorrespondentUpdateSafe = useCallback( + async (id, payload) => { + if (!onCorrespondentUpdate) { + return; + } + await onCorrespondentUpdate(id, payload); + }, + [onCorrespondentUpdate], + ); + + const handleCorrespondentDeleteSafe = useCallback( + async (id) => { + if (!onCorrespondentDelete) { + return; + } + await onCorrespondentDelete(id); + }, + [onCorrespondentDelete], + ); + const correspondentsModal = useMemo(() => { if (activeModal !== CORRESPONDENTS_MODAL) { return null; @@ -122,8 +189,8 @@ export const useManagementModals = ({ titleTag="h3" titleProps={{ id: 'correspondents-modal-title' }} actions={( - )} /> @@ -131,9 +198,9 @@ export const useManagementModals = ({
@@ -144,9 +211,9 @@ export const useManagementModals = ({ activeModal, closeActiveModal, correspondents, - onCorrespondentCreate, - onCorrespondentDelete, - onCorrespondentUpdate, + handleCorrespondentCreateSafe, + handleCorrespondentDeleteSafe, + handleCorrespondentUpdateSafe, refreshCorrespondents, setStatusMessage, ]); diff --git a/frontend/src/app/useWorkspaceSelection.js b/frontend/src/app/useWorkspaceSelection.ts similarity index 68% rename from frontend/src/app/useWorkspaceSelection.js rename to frontend/src/app/useWorkspaceSelection.ts index db31f4b..c2b13a1 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; + resolveFolderRowKey?: (id: string | number) => RowKey | null; + 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 = typeof entry === 'string' ? entry : entry?.rowKey; + (entry: SelectionEntry | string | null, event?: unknown) => { + const rowKey = entry && Object(entry) === entry + ? (entry as SelectionEntry).rowKey ?? null + : (entry as string | 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.js deleted file mode 100644 index 1d59a80..0000000 --- a/frontend/src/app/useWorkspaceSurface.js +++ /dev/null @@ -1,168 +0,0 @@ -import React, { useCallback, useEffect, useMemo } 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'; - -export const useWorkspaceSurface = ({ - sidebarHidden, - onExpandSidebar, - documentsTableProps, - detailPanelProps, - detailPanelOpen, - viewMode, - deskWorkspaceProps, - previewWorkspaceDocument, - previewWorkspaceEntry, - previewDocumentId, - ensureAssetUrl, - ensurePreviewData, - getDocumentAsset, - resolveApiPath, - notifyApiError, - closeDocumentPreview, - parentBreadcrumb, - onNavigateParent, -}) => { - const { registerDetailCloseHandler, setDetailActive } = usePanelManager(); - - useEffect(() => { - const handler = detailPanelProps?.onClose || null; - registerDetailCloseHandler(handler); - return () => registerDetailCloseHandler(null); - }, [registerDetailCloseHandler, detailPanelProps?.onClose]); - - useEffect(() => { - setDetailActive(Boolean(detailPanelOpen)); - return () => setDetailActive(false); - }, [detailPanelOpen, setDetailActive]); - - const renderSidebarToggle = useCallback(() => { - if (!sidebarHidden) { - return null; - } - return ( - - ); - }, [sidebarHidden, onExpandSidebar]); - - const documentsSurface = useMemo(() => { - if (!documentsTableProps) { - return null; - } - return createDocumentsSurface({ - tableProps: documentsTableProps, - parentBreadcrumb, - onNavigateParent: parentBreadcrumb ? onNavigateParent : null, - renderSidebarToggle, - detailProps: detailPanelProps, - detailOpen: detailPanelOpen, - }); - }, [ - documentsTableProps, - parentBreadcrumb, - onNavigateParent, - renderSidebarToggle, - detailPanelProps, - detailPanelOpen, - ]); - - const showPreviewWorkspace = Boolean(previewDocumentId); - - const previewSurface = useMemo(() => { - if (!showPreviewWorkspace) { - return null; - } - const detailExtras = detailPanelProps || {}; - const { - tagLookupById, - tags: tagOptions, - onTagAdd, - onTagRemove, - correspondents, - onCorrespondentAdd, - onCorrespondentRemove, - onUpdateTitle, - onUpdateIssued, - resolveFolderPath, - onFolderNavigate, - } = detailExtras; - return createDocumentViewerSurface({ - document: previewWorkspaceDocument, - previewEntry: previewWorkspaceEntry, - ensureAssetUrl, - ensurePreviewData, - getDocumentAsset, - resolveApiPath, - notifyApiError, - onClose: closeDocumentPreview, - renderSidebarToggle, - tagLookupById, - tagOptions, - onTagAdd, - onTagRemove, - correspondents, - onCorrespondentAdd, - onCorrespondentRemove, - onUpdateTitle, - onUpdateIssued, - resolveFolderPath, - onFolderNavigate, - }); - }, [ - showPreviewWorkspace, - previewWorkspaceDocument, - previewWorkspaceEntry, - ensureAssetUrl, - ensurePreviewData, - getDocumentAsset, - resolveApiPath, - notifyApiError, - closeDocumentPreview, - renderSidebarToggle, - detailPanelProps, - ]); - - const workspaceSurface = useMemo(() => { - if (viewMode !== 'desk') { - return null; - } - return createDesktopSurface({ - workspaceProps: deskWorkspaceProps, - renderSidebarToggle, - parentBreadcrumb, - onNavigateParent: parentBreadcrumb ? onNavigateParent : null, - detailProps: detailPanelProps, - detailOpen: detailPanelOpen, - }); - }, [ - viewMode, - deskWorkspaceProps, - renderSidebarToggle, - parentBreadcrumb, - onNavigateParent, - detailPanelProps, - detailPanelOpen, - ]); - - const surface = useMemo(() => { - if (showPreviewWorkspace) { - return previewSurface; - } - if (viewMode === 'desk') { - return workspaceSurface; - } - return documentsSurface; - }, [showPreviewWorkspace, viewMode, previewSurface, workspaceSurface, documentsSurface]); - - return { surface }; -}; diff --git a/frontend/src/app/useWorkspaceSurface.tsx b/frontend/src/app/useWorkspaceSurface.tsx new file mode 100644 index 0000000..99ed49a --- /dev/null +++ b/frontend/src/app/useWorkspaceSurface.tsx @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import type { ReactNode } from 'react'; +import { SidebarExpandIcon } from '../ui/icons'; +import DocumentsPanel from '../documents/panel/DocumentsPanel'; +import DocumentViewerPanel from '../preview/DocumentViewerPanel'; +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 WorkspaceSurface = { content: ReactNode; detail?: ReactNode | null } | null; + +interface UseWorkspaceSurfaceArgs { + sidebarHidden?: boolean; + onExpandSidebar?: () => void; + documentsTableProps?: Record | null; + detailPanelProps?: (Record & { onClose?: () => void }) | null; + detailPanelOpen?: boolean; + previewWorkspaceDocument?: unknown; + documentLink?: unknown; + previewDocumentId?: Identifier | null; + ensureAssetUrl?: EnsureAssetUrl; + ensurePreviewData?: EnsurePreviewData; + getDocumentAsset?: GetDocumentAsset; + resolveApiPath?: ResolveApiPath; + notifyApiError?: NotifyApiError; + closeDocumentPreview?: () => void; +} + +interface UseWorkspaceSurfaceResult { + surface: WorkspaceSurface; +} + +export const useWorkspaceSurface = ({ + sidebarHidden = false, + onExpandSidebar, + documentsTableProps, + detailPanelProps, + detailPanelOpen = false, + previewWorkspaceDocument, + documentLink, + previewDocumentId, + ensureAssetUrl, + ensurePreviewData, + getDocumentAsset, + resolveApiPath, + notifyApiError, + closeDocumentPreview, +}: UseWorkspaceSurfaceArgs): UseWorkspaceSurfaceResult => { + const { registerDetailCloseHandler, setDetailActive } = usePanelManager(); + + useEffect(() => { + const handler = detailPanelProps?.onClose || null; + registerDetailCloseHandler(handler); + return () => registerDetailCloseHandler(null); + }, [registerDetailCloseHandler, detailPanelProps?.onClose]); + + useEffect(() => { + setDetailActive(Boolean(detailPanelOpen)); + return () => setDetailActive(false); + }, [detailPanelOpen, setDetailActive]); + + const renderSidebarToggle = useCallback<() => ReactNode>(() => { + if (!sidebarHidden) { + return null; + } + return ( + + ); + }, [sidebarHidden, onExpandSidebar]); + + const documentsSurface = useMemo(() => { + if (!documentsTableProps) { + return null; + } + + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; + + const detail = detailPanelOpen && detailPanelProps + ? (() => { + const { onClose, onOpenPreview, tags: tagOptions, ...restDetailProps } = detailPanelProps; + return ( + + ); + })() + : null; + + return { + content: ( + + ), + detail, + }; + }, [ + documentsTableProps, + renderSidebarToggle, + detailPanelOpen, + detailPanelProps, + ]); + + const showPreviewWorkspace = Boolean(previewDocumentId); + + const previewSurface = useMemo(() => { + if (!showPreviewWorkspace) { + return null; + } + + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; + const detailExtras = detailPanelProps || {}; + const { + tagLookupById, + tags: tagOptions, + onTagAdd, + onTagRemove, + correspondents, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + resolveFolderPath, + } = detailExtras; + + return { + content: ( + + ), + detail: null, + }; + }, [ + showPreviewWorkspace, + previewWorkspaceDocument, + documentLink, + ensurePreviewData, + ensureAssetUrl, + getDocumentAsset, + resolveApiPath, + notifyApiError, + renderSidebarToggle, + closeDocumentPreview, + detailPanelProps, + ]); + + const surface = useMemo(() => { + if (showPreviewWorkspace) { + return previewSurface; + } + return documentsSurface; + }, [showPreviewWorkspace, previewSurface, documentsSurface]); + + return { surface }; +}; 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.js deleted file mode 100644 index 44054a4..0000000 --- a/frontend/src/asset_manager.js +++ /dev/null @@ -1,419 +0,0 @@ -export const getAssetFromGroup = (assets, assetType) => { - if (!assetType || !assets) { - return null; - } - - if (Array.isArray(assets)) { - return assets.find((entry) => entry?.asset_type === assetType) || null; - } - - return assets?.[assetType] || null; -}; - -export const getAssetFromVersion = (currentVersion, assetType) => { - if (!currentVersion) { - return null; - } - return getAssetFromGroup(currentVersion.assets, assetType); -}; - -const normalizeAssetObjects = (objects) => { - if (!Array.isArray(objects)) { - return []; - } - return objects - .filter((entry) => Number.isInteger(entry?.ordinal)) - .slice() - .sort((a, b) => a.ordinal - b.ordinal); -}; - -const mergeAssetObjects = (existingObjects, incomingObjects) => { - const merged = new Map(); - - normalizeAssetObjects(existingObjects).forEach((entry) => { - merged.set(entry.ordinal, { ...entry }); - }); - - normalizeAssetObjects(incomingObjects).forEach((entry) => { - const current = merged.get(entry.ordinal) || {}; - merged.set(entry.ordinal, { ...current, ...entry }); - }); - - return [...merged.entries()] - .sort((a, b) => a[0] - b[0]) - .map(([, value]) => value); -}; - -export class AssetView { - constructor(asset) { - this.asset = asset || null; - this._objectsRef = null; - this._sortedObjects = []; - } - - getCardinality() { - if (!this.asset) { - return 0; - } - - const reported = Number(this.asset.cardinality); - if (Number.isFinite(reported) && reported > 0) { - return reported; - } - - const objectsCount = this.getObjects().length; - if (objectsCount > 0) { - return objectsCount; - } - - return this.asset.metadata ? 1 : 0; - } - - getObjects() { - if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) { - return []; - } - - if (this._objectsRef === this.asset.objects) { - return this._sortedObjects; - } - - this._objectsRef = this.asset.objects; - this._sortedObjects = normalizeAssetObjects(this.asset.objects); - return this._sortedObjects; - } - - getObject(ordinal = 1) { - const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal); - if (fromObjects) { - return fromObjects; - } - - if (ordinal === 1 && this.asset) { - if (this.asset.url || this.asset.metadata) { - return { - ordinal: 1, - url: this.asset.url || null, - metadata: this.asset.metadata || null, - expires_at: this.asset.expiresAt ?? null, - }; - } - } - - return null; - } - - getPrimaryObject() { - return this.getObject(1); - } - - getPrimaryMetadata() { - return this.getPrimaryObject()?.metadata || null; - } - - getPrimaryUrl() { - return this.getPrimaryObject()?.url || null; - } - - hasObject(ordinal) { - return Boolean(this.getObject(ordinal)); - } -} - -export const createAssetView = (asset) => new AssetView(asset); - -export const resolveDocumentAssetUrl = ( - doc, - type, - { ensureAssetUrl, getAsset, ensureOptions, objectOrdinal = 1 } = {}, -) => { - if (!doc || !type) { - return null; - } - const asset = typeof getAsset === 'function' ? getAsset(doc, type) : null; - if (!asset) { - return null; - } - const view = createAssetView(asset); - const object = view.getObject(objectOrdinal); - const url = object?.url || (objectOrdinal === 1 ? view.getPrimaryUrl() : null); - const expiresAt = typeof object?.expires_at === 'number' - ? object.expires_at - : objectOrdinal === 1 && typeof asset.expiresAt === 'number' - ? asset.expiresAt - : null; - const now = Date.now(); - if (url && (!expiresAt || expiresAt > now)) { - return url; - } - if (doc.id && asset.id && typeof ensureAssetUrl === 'function') { - const force = Boolean(url && expiresAt && expiresAt <= now); - const options = { - force, - start: objectOrdinal, - limit: 1, - ...(ensureOptions || {}), - }; - if (!options.start) { - options.start = objectOrdinal; - } - if (!options.limit) { - options.limit = 1; - } - ensureAssetUrl(doc.id, asset, options).catch(() => {}); - } - return null; -}; - -class AssetManager { - constructor({ api, assetPresignTtlMs }) { - this.api = api; - this.assetPresignTtlMs = assetPresignTtlMs; - this.assetCache = new Map(); - this.assetInflight = new Map(); - } - - setApi(api) { - this.api = api; - } - - rememberAsset(entry) { - if (entry?.id) { - this.assetCache.set(entry.id, entry); - } - } - - hydrateAsset(asset) { - if (!asset || !asset.id) { - return asset; - } - const cached = this.assetCache.get(asset.id); - if (!cached) { - const normalized = mergeAssetObjects(null, asset.objects); - if (normalized.length) { - return { ...asset, objects: normalized }; - } - return asset; - } - const merged = { ...cached, ...asset }; - if (cached.url && !asset.url) { - merged.url = cached.url; - } - if (cached.expiresAt) { - const cachedExpires = Number(cached.expiresAt) || null; - const assetExpires = Number(asset.expiresAt) || null; - if (!assetExpires || (cachedExpires && cachedExpires > assetExpires)) { - merged.expiresAt = cachedExpires; - } - } - const mergedObjects = mergeAssetObjects(cached.objects, asset.objects); - if (mergedObjects.length) { - merged.objects = mergedObjects; - } - return merged; - } - - hydrateDocument(document) { - if (!document) { - return document; - } - - const currentVersion = document.current_version || null; - if (!currentVersion) { - return document; - } - - let changed = false; - let nextAssets = currentVersion.assets; - - if (nextAssets && !Array.isArray(nextAssets)) { - const hydrated = {}; - Object.keys(nextAssets).forEach((key) => { - hydrated[key] = this.hydrateAsset(nextAssets[key]); - if (hydrated[key] !== nextAssets[key]) { - changed = true; - } - }); - if (changed) { - nextAssets = { ...nextAssets, ...hydrated }; - } - } else if (Array.isArray(nextAssets)) { - const hydratedList = nextAssets.map((item) => this.hydrateAsset(item)); - if ( - hydratedList.length !== nextAssets.length || - hydratedList.some((item, index) => item !== nextAssets[index]) - ) { - changed = true; - nextAssets = hydratedList; - } - } - - if (!changed) { - return document; - } - - const nextCurrentVersion = { ...currentVersion, assets: nextAssets }; - return { ...document, current_version: nextCurrentVersion }; - } - - hydrateDocuments(documents) { - if (!Array.isArray(documents)) { - return documents; - } - return documents.map((doc) => this.hydrateDocument(doc)); - } - - hydrateDetail(detail) { - if (!detail) { - return detail; - } - let changed = false; - const next = { ...detail }; - - if (detail.document) { - const hydratedDocument = this.hydrateDocument(detail.document); - if (hydratedDocument !== detail.document) { - next.document = hydratedDocument; - changed = true; - } - } - - if (Array.isArray(detail.assets)) { - const hydratedAssets = detail.assets.map((item) => this.hydrateAsset(item)); - if ( - hydratedAssets.length !== detail.assets.length || - hydratedAssets.some((item, index) => item !== detail.assets[index]) - ) { - next.assets = hydratedAssets; - changed = true; - } - } - - return changed ? next : detail; - } - - hydrateFolderContents(contents) { - if (!contents) { - return contents; - } - const next = { ...contents }; - if (Array.isArray(contents.documents)) { - next.documents = this.hydrateDocuments(contents.documents); - } - if (contents.document) { - next.document = this.hydrateDocument(contents.document); - } - return next; - } - - ensureAsset(documentId, asset, { force = false, start = null, limit = null } = {}) { - if (!documentId || !asset?.id) { - return Promise.resolve(asset || null); - } - - const requestedStart = Number.isInteger(start) && start > 0 ? start : 1; - const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1; - const requestedEnd = requestedStart + requestedLimit - 1; - - const baseAsset = this.assetCache.get(asset.id) || asset; - const view = createAssetView(baseAsset); - const assetExpiresAt = typeof baseAsset.expiresAt === 'number' ? baseAsset.expiresAt : null; - const now = Date.now(); - - const isOrdinalSatisfied = (ordinal) => { - const object = view.getObject(ordinal); - if (!object) { - return false; - } - if (!object.url) { - return false; - } - if (typeof object.expires_at === 'number') { - return object.expires_at > now; - } - if (ordinal === 1 && baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) { - return true; - } - return true; - }; - - let needsFetch = force; - if (!needsFetch) { - for (let ordinal = requestedStart; ordinal <= requestedEnd; ordinal += 1) { - if (!isOrdinalSatisfied(ordinal)) { - needsFetch = true; - break; - } - } - } - - if (!needsFetch) { - this.rememberAsset(baseAsset); - return Promise.resolve(baseAsset); - } - - const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`; - if (!force && this.assetInflight.has(inflightKey)) { - return this.assetInflight.get(inflightKey); - } - - if (!this.api) { - return Promise.reject(new Error('AssetManager API client is not configured.')); - } - - const params = {}; - if (Number.isInteger(start) && start > 0) { - params.start = start; - } - if (Number.isInteger(limit) && limit > 0) { - params.limit = limit; - } - - const requestConfig = Object.keys(params).length ? { params } : undefined; - - const request = this.api - .get(`/assets/${asset.id}`, requestConfig) - .then(({ data }) => { - const incomingObjects = Array.isArray(data.objects) ? data.objects : []; - const cachedEntry = this.assetCache.get(asset.id) || baseAsset; - const mergedObjects = mergeAssetObjects(cachedEntry?.objects, incomingObjects); - const combined = { ...cachedEntry, ...asset, ...data, objects: mergedObjects }; - const view = createAssetView(combined); - const primaryObject = view.getPrimaryObject(); - const expiresAt = typeof primaryObject?.expires_at === 'number' - ? primaryObject.expires_at - : Date.now() + this.assetPresignTtlMs; - const cardinality = (() => { - const reported = Number(data.cardinality ?? asset.cardinality ?? cachedEntry?.cardinality); - const objectsCount = mergedObjects.length; - if (Number.isFinite(reported) && reported > 0) { - return Math.max(reported, objectsCount) || null; - } - return objectsCount || null; - })(); - - const entry = { - ...combined, - cardinality, - url: view.getPrimaryUrl(), - expiresAt, - }; - - this.rememberAsset(entry); - return entry; - }) - .finally(() => { - this.assetInflight.delete(inflightKey); - }); - - this.assetInflight.set(inflightKey, request); - return request; - } - - reset() { - this.assetCache.clear(); - this.assetInflight.clear(); - } -} - -export default AssetManager; diff --git a/frontend/src/asset_manager.ts b/frontend/src/asset_manager.ts new file mode 100644 index 0000000..deb6161 --- /dev/null +++ b/frontend/src/asset_manager.ts @@ -0,0 +1,317 @@ +import type { AxiosInstance } from 'axios'; + +export type Identifier = string | number; + +type Nullable = T | null; + +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; + expires_at?: number | 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 const resolveAssetExpiresAt = ( + asset?: { expiresAt?: number | null; expires_at?: number | null } | null, +): number | null => { + const camel = Number(asset?.expiresAt); + if (Number.isFinite(camel)) { + return camel; + } + const snake = Number(asset?.expires_at); + if (Number.isFinite(snake)) { + return snake; + } + return null; +}; + +export type EnsureAssetUrl = ( + documentId: Identifier, + asset: AssetLike, + options?: { force?: boolean; [key: string]: unknown }, +) => Promise; + +export type GetAsset = (document: DocumentLike, assetType: string) => Nullable; + +export const getAssetFromGroup = ( + assets?: AssetLike[] | Record | null, + assetType: string = '', +): Nullable => { + if (!assetType || !assets) { + return null; + } + + if (Array.isArray(assets)) { + return assets.find((entry) => entry?.asset_type === assetType) || null; + } + + return assets?.[assetType] || null; +}; + +export const getAssetFromVersion = (currentVersion: Nullable, assetType: string) => { + if (!currentVersion) { + return null; + } + return getAssetFromGroup(currentVersion.assets ?? null, assetType); +}; + +const normalizeAssetObjects = (objects?: AssetObject[] | null): AssetObject[] => { + if (!Array.isArray(objects)) { + return []; + } + return objects + .filter((entry) => Number.isInteger(entry?.ordinal)) + .slice() + .sort((a, b) => a.ordinal - b.ordinal); +}; + +export class AssetView { + asset: AssetLike | null; + private _objectsRef: AssetObject[] | null; + private _sortedObjects: AssetObject[]; + + constructor(asset?: AssetLike | null) { + this.asset = asset || null; + this._objectsRef = null; + this._sortedObjects = []; + } + + getCardinality(): number { + if (!this.asset) { + return 0; + } + + const objectsCount = this.getObjects().length; + if (objectsCount > 0) { + return objectsCount; + } + + if (this.asset.url || this.asset.metadata) { + return 1; + } + + return 0; + } + + getObjects(): AssetObject[] { + if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) { + return []; + } + + if (this._objectsRef === this.asset.objects) { + return this._sortedObjects; + } + + this._objectsRef = this.asset.objects; + this._sortedObjects = normalizeAssetObjects(this.asset.objects); + return this._sortedObjects; + } + + getObject(ordinal = 1): AssetObject | null { + const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal); + if (fromObjects) { + return fromObjects; + } + + if (ordinal === 1 && this.asset) { + if (this.asset.url || this.asset.metadata) { + return { + ordinal: 1, + url: this.asset.url || null, + metadata: this.asset.metadata || null, + expires_at: resolveAssetExpiresAt(this.asset), + }; + } + } + + return null; + } + + getPrimaryObject(): AssetObject | null { + return this.getObject(1); + } + + getPrimaryMetadata(): Record | null { + return this.getPrimaryObject()?.metadata || null; + } + + getPrimaryUrl(): string | null { + return this.getPrimaryObject()?.url || null; + } + + hasObject(ordinal: number): boolean { + return Boolean(this.getObject(ordinal)); + } +} + +export const createAssetView = (asset?: AssetLike | null): AssetView => new AssetView(asset); + +export const resolveDocumentAssetUrl = ( + doc: Nullable, + type: string, + { + ensureAssetUrl, + getAsset, + ensureOptions, + }: { + ensureAssetUrl?: EnsureAssetUrl; + getAsset?: GetAsset; + ensureOptions?: { force?: boolean; [key: string]: unknown }; + } = {}, +): Nullable => { + if (!doc || !type) { + return null; + } + const asset = getAsset ? getAsset(doc, type) : null; + if (!asset) { + return null; + } + const view = createAssetView(asset); + const object = view.getPrimaryObject(); + const url = object?.url || view.getPrimaryUrl(); + const expiresAt = Number.isFinite(object?.expires_at) + ? Number(object?.expires_at) + : resolveAssetExpiresAt(asset); + const now = Date.now(); + if (url && (!expiresAt || expiresAt > now)) { + return url; + } + if (doc.id && asset.id && ensureAssetUrl) { + const force = Boolean(url && expiresAt && expiresAt <= now); + const options: { force: boolean; [key: string]: unknown } = { + force, + ...(ensureOptions || {}), + }; + ensureAssetUrl(doc.id, asset, options).catch(() => {}); + } + return null; +}; + +class AssetManager { + 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: AxiosInstance | null) { + this.api = api; + } + + rememberAsset(entry?: Nullable) { + if (entry?.id) { + this.assetCache.set(entry.id, entry); + } + } + + ensureAsset( + documentId?: Identifier | null, + asset?: Nullable, + { force = false }: { force?: boolean } = {}, + ): Promise> { + if (!documentId || !asset?.id) { + return Promise.resolve(asset ?? null); + } + + const baseAsset = this.assetCache.get(asset.id) || asset; + const view = createAssetView(baseAsset); + const assetExpiresAt = resolveAssetExpiresAt(baseAsset); + const now = Date.now(); + + const isPrimarySatisfied = () => { + const object = view.getObject(1); + if (object?.url) { + const objectExpiresAt = Number.isFinite(object.expires_at) ? Number(object.expires_at) : null; + if (!objectExpiresAt || objectExpiresAt > now) { + return true; + } + } + if (baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) { + return true; + } + return false; + }; + + let needsFetch = force; + if (!needsFetch) { + needsFetch = !isPrimarySatisfied(); + } + + if (!needsFetch) { + this.rememberAsset(baseAsset); + return Promise.resolve(baseAsset); + } + + const inflightKey = `${documentId}:${asset.id}`; + if (!force && this.assetInflight.has(inflightKey)) { + return this.assetInflight.get(inflightKey); + } + + if (!this.api) { + return Promise.reject(new Error('AssetManager API client is not configured.')); + } + + const request: Promise = this.api + .get(`/assets/${asset.id}`) + .then(({ data }) => { + const cachedEntry = this.assetCache.get(asset.id) || baseAsset; + const combined = { ...cachedEntry, ...asset, ...data }; + const expiresAt = + resolveAssetExpiresAt(data) + ?? resolveAssetExpiresAt(combined) + ?? Date.now() + this.assetPresignTtlMs; + const entry = { + ...combined, + expiresAt, + }; + + this.rememberAsset(entry); + return entry; + }) + .finally(() => { + this.assetInflight.delete(inflightKey); + }); + + this.assetInflight.set(inflightKey, request); + return request; + } + + reset() { + this.assetCache.clear(); + this.assetInflight.clear(); + } +} + +export default AssetManager; diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg new file mode 100644 index 0000000..8746aec --- /dev/null +++ b/frontend/src/assets/logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/assets/logo.webp b/frontend/src/assets/logo.webp new file mode 100644 index 0000000..27b3ae7 Binary files /dev/null and b/frontend/src/assets/logo.webp differ diff --git a/frontend/src/assets/logo_lines.svg b/frontend/src/assets/logo_lines.svg new file mode 100644 index 0000000..d1919dd --- /dev/null +++ b/frontend/src/assets/logo_lines.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/logo_small.webp b/frontend/src/assets/logo_small.webp new file mode 100644 index 0000000..cfeccc6 Binary files /dev/null and b/frontend/src/assets/logo_small.webp differ 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..ef3f457 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,11 +121,11 @@ function CorrespondentsPanel({ [handleSave, cancelEdit], ); - const renderUsage = useCallback((usage) => { + const renderUsage = useCallback((usage?: CorrespondentUsage) => { if (!usage) { return '0'; } - const total = typeof usage.total === 'number' ? usage.total : 0; + const total = Number.isFinite(usage.total) ? Number(usage.total) : 0; return total.toString(); }, []); diff --git a/frontend/src/desktop/DesktopDocumentCard.jsx b/frontend/src/desktop/DesktopDocumentCard.tsx similarity index 60% rename from frontend/src/desktop/DesktopDocumentCard.jsx rename to frontend/src/desktop/DesktopDocumentCard.tsx index 7bb23f8..944e88f 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; + onInspectDocument?: (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, @@ -18,7 +57,7 @@ const DesktopDocumentCard = ({ getDocumentAsset, handleNavigatorSnapshot, cardPointerHandlers, - onDocumentOpen, + onInspectDocument, onTagDragEnter, onTagDragOver, onTagDragLeave, @@ -52,16 +91,33 @@ 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); - onDocumentOpen?.(doc.id); + onInspectDocument?.(doc.id); } }} > @@ -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.jsx deleted file mode 100644 index 201c637..0000000 --- a/frontend/src/desktop/DesktopPreviewCard.jsx +++ /dev/null @@ -1,146 +0,0 @@ -import React, { useEffect } from 'react'; -import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; -import { useAssetNavigator } from '../hooks/useAssetNavigator'; -import { preventAll } from './events'; - -const DesktopPreviewCard = ({ - doc, - title, - ensureAssetUrl, - getDocumentAsset, - prefetch = 3, - onNavigatorSnapshot, - shouldLoad = true, -}) => { - const navigator = useAssetNavigator({ - document: doc, - assetType: 'preview', - ensureAssetUrl: shouldLoad ? ensureAssetUrl : null, - getAsset: getDocumentAsset, - prefetch, - }); - - const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator; - const docId = doc?.id ?? null; - - const metadataWidth = Number(currentMetadata?.width); - const metadataHeight = Number(currentMetadata?.height); - - useEffect(() => { - if (!onNavigatorSnapshot || !docId) { - return undefined; - } - const snapshot = { - url: currentUrl || null, - alt: title, - canGoPrev, - canGoNext, - goPrev: navigator.goPrev, - goNext: navigator.goNext, - ordinal, - width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null, - height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null, - }; - onNavigatorSnapshot(docId, snapshot); - return () => onNavigatorSnapshot(docId, null); - }, [ - docId, - currentUrl, - title, - canGoPrev, - canGoNext, - ordinal, - metadataWidth, - metadataHeight, - navigator.goPrev, - navigator.goNext, - onNavigatorSnapshot, - ]); - - const hasPreview = Boolean(currentUrl); - const cardClasses = ['desk-item__card']; - if (!hasPreview) cardClasses.push('desk-item__card--empty'); - const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext); - - return ( -
{ - if (event instanceof DragEvent) { - event.preventDefault(); - } - }} - > - {hasPreview ? ( - {title} event.preventDefault()} - /> - ) : ( -
-
DOC
-
- {title} -
-
- )} - {showNav ? ( -
- - -
- ) : null} -
- ); -}; - -export default DesktopPreviewCard; - diff --git a/frontend/src/desktop/DesktopPreviewCard.tsx b/frontend/src/desktop/DesktopPreviewCard.tsx new file mode 100644 index 0000000..12c1501 --- /dev/null +++ b/frontend/src/desktop/DesktopPreviewCard.tsx @@ -0,0 +1,122 @@ +import { useEffect } from 'react'; +import type { JSX } from 'react'; +import { useAssetNavigator } from '../hooks/useAssetNavigator'; + +type Identifier = string | number; + +interface DocumentLike { + id?: Identifier; + title?: string; + [key: string]: unknown; +} + +interface AssetLike { + id?: Identifier; + url?: string | null; + expires_at?: number | null; + metadata?: Record | null; + [key: string]: unknown; +} + +type EnsureAssetUrl = ( + documentId: Identifier, + asset: AssetLike, + options?: { force?: boolean; [key: string]: unknown }, +) => Promise; + +type GetDocumentAsset = (document: DocumentLike | null, assetType: string) => AssetLike | null; + +interface NavigatorSnapshot { + url: string | null; + alt?: string; + width: number | null; + height: number | null; +} + +interface DesktopPreviewCardProps { + doc: DocumentLike | null; + title?: string; + ensureAssetUrl?: EnsureAssetUrl | null; + getDocumentAsset: GetDocumentAsset; + onNavigatorSnapshot?: (docId: Identifier, snapshot: NavigatorSnapshot | null) => void; + shouldLoad?: boolean; +} + +const DesktopPreviewCard = ({ + doc, + title, + ensureAssetUrl, + getDocumentAsset, + onNavigatorSnapshot, + shouldLoad = true, +}: DesktopPreviewCardProps): JSX.Element => { + const navigator = useAssetNavigator({ + document: doc, + assetType: 'thumbnail', + ensureAssetUrl: shouldLoad ? ensureAssetUrl : null, + getAsset: getDocumentAsset, + }); + + const { currentUrl, currentMetadata } = navigator; + const docId = doc?.id ?? null; + + const metadataWidth = Number((currentMetadata as { width?: number } | null)?.width); + const metadataHeight = Number((currentMetadata as { height?: number } | null)?.height); + + useEffect(() => { + if (!onNavigatorSnapshot || !docId) { + return undefined; + } + if (!currentUrl) { + onNavigatorSnapshot(docId, null); + return undefined; + } + const snapshot = { + url: currentUrl, + alt: title, + width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null, + height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null, + }; + onNavigatorSnapshot(docId, snapshot); + return () => onNavigatorSnapshot(docId, null); + }, [ + docId, + currentUrl, + title, + metadataWidth, + metadataHeight, + onNavigatorSnapshot, + ]); + + const hasPreview = Boolean(currentUrl); + const cardClasses = ['desk-item__card']; + if (!hasPreview) cardClasses.push('desk-item__card--empty'); + return ( +
{ + if (event instanceof DragEvent) { + event.preventDefault(); + } + }} + > + {hasPreview ? ( + {title} event.preventDefault()} + /> + ) : ( +
+
DOC
+
+ {title} +
+
+ )} +
+ ); +}; + +export default DesktopPreviewCard; diff --git a/frontend/src/desktop/DesktopWorkspace.jsx b/frontend/src/desktop/DesktopWorkspace.tsx similarity index 64% rename from frontend/src/desktop/DesktopWorkspace.jsx rename to frontend/src/desktop/DesktopWorkspace.tsx index 7b94936..fe0337b 100644 --- a/frontend/src/desktop/DesktopWorkspace.jsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -8,6 +8,7 @@ import React, { useSyncExternalStore, } from 'react'; import { resolveDocumentAssetUrl } from '../asset_manager'; +import type { EnsureAssetUrl, GetAsset } from '../asset_manager'; import { formatTransform } from './math'; import useDocumentDrag from './useDocumentDrag'; import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; @@ -28,50 +29,212 @@ 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; +type DocumentLinkLike = { url?: string | null; contentType?: string | null }; +type OverlaySource = { url: string; alt?: string | null; contentType?: string | null }; + +export interface DeskDocument { + id?: Identifier | null; + title?: string; + tags?: TagLike[] | null; + documentLink?: OverlaySource | null; + [key: string]: unknown; +} + +interface NavigatorSnapshot { + url: string | null; + alt?: string | null; + width?: number | null; + height?: number | null; +} + +type OverlayOriginHint = { + rotation?: number; + scale?: number; + width?: number; + height?: number; +}; + +interface OverlayOriginTransform { + rotation: number; + scaleX: number; + scaleY: number; + baseWidth: number; + baseHeight: number; +} + +interface OverlayDisplay { + url: string; + alt?: string | null; + contentType?: string | null; +} + +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[]; + onInspectDocument?: (...args: unknown[]) => void; + onEntryPointer?: (...args: unknown[]) => void; + onDocumentStackSelect?: (docIds: Identifier[]) => void; + onPromoteSelection?: (...args: unknown[]) => void; + onAssignTagToDocument?: (...args: unknown[]) => void; + ensureAssetUrl?: EnsureAssetUrl; + getDocumentAsset?: GetAsset; + activeTagIds?: Array; + selectedDocumentIds?: Identifier[]; + onClearSelection?: () => 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) => 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; + 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; + overlayDocument: DeskDocument | null; + onEntryPointer?: DesktopWorkspaceProps['onEntryPointer']; + onDocumentStackSelect?: DesktopWorkspaceProps['onDocumentStackSelect']; + onPromoteSelection?: DesktopWorkspaceProps['onPromoteSelection']; + selectedDocumentIds: Identifier[]; + onClearSelection?: DesktopWorkspaceProps['onClearSelection']; + documentLookup: Map; + resolveBaseMetrics: (doc: DeskDocument | null, cardWidth: number, cardHeight: number) => { + baseWidth: number; + baseHeight: number; + baseScale: number; + }; + bringToFront: (docId: Identifier | null) => void; + setDraggingId: (value: string | null) => void; + canvasSize: { width: number; height: number }; + openOverlayForDoc: (docId: Identifier | null, originInfo?: OverlayOriginHint | null) => void; + recalcVisibleDocIds: () => void; + dragSettings: DragSettings; + onInspectDocument?: DesktopWorkspaceProps['onInspectDocument']; + markLayoutDirty: () => void; +} + const DEBUG_DRAG = false; const DEBUG_FOCUS = false; -const DesktopWorkspace = ({ +const defaultGetDocumentAsset: GetAsset = () => null; + +const DesktopWorkspace: React.FC = ({ documents = [], - searchResults = null, - onDocumentOpen, onInspectDocument = null, onEntryPointer = null, onDocumentStackSelect = null, onPromoteSelection = null, onAssignTagToDocument = null, - onRemoveTagFromDocument = null, ensureAssetUrl = null, - getDocumentAsset = () => null, + getDocumentAsset = defaultGetDocumentAsset, activeTagIds = [], selectedDocumentIds = [], onClearSelection = null, - detailPanelOpen = false, - onCloseDetailPanel = null, tenantId = null, viewId = 'default', + documentLinks, + ensureDownloadUrl, }) => { - const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); + const items = useMemo(() => documents, [documents]); const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:')); + const documentLinkMap = documentLinks instanceof Map ? documentLinks : null; - 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 [overlaySource, setOverlaySource] = useState(null); + const [, 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): 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 +244,7 @@ const DesktopWorkspace = ({ return map; }, [items]); - const engineRef = useRef(null); + const engineRef = useRef(null); if (!engineRef.current) { engineRef.current = new WorkspaceEngine({ allowLayoutPersistence, @@ -89,16 +252,15 @@ const DesktopWorkspace = ({ viewId, }); } - const engine = engineRef.current; + const engine = engineRef.current as WorkspaceEngine; useEffect(() => { engine.updateConfig({ allowLayoutPersistence, tenantId, viewId }); }, [engine, allowLayoutPersistence, tenantId, viewId]); useEffect(() => { - const nextItems = searchResults ? searchResults : documents; - engine.setItems(nextItems || []); - }, [engine, documents, searchResults]); + engine.setItems(items || []); + }, [engine, items]); useEffect(() => { const map = new Map(); @@ -115,7 +277,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 +303,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) => { engine.bringToFront(docId); }, [engine]); @@ -156,11 +318,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) { @@ -174,13 +336,13 @@ const DesktopWorkspace = ({ if (existing && existing.width === normalized.width && existing.height === normalized.height) { return; } - const next = new Map(docSizeMapRef.current); + const next = new Map(docSizeMapRef.current); next.set(docKey, { ...normalized, source: 'snapshot' }); docSizeMapRef.current = next; 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; @@ -202,11 +364,6 @@ const DesktopWorkspace = ({ prevSnapshot && prevSnapshot.url === snapshot.url && prevSnapshot.alt === snapshot.alt && - prevSnapshot.canGoPrev === snapshot.canGoPrev && - prevSnapshot.canGoNext === snapshot.canGoNext && - prevSnapshot.goPrev === snapshot.goPrev && - prevSnapshot.goNext === snapshot.goNext && - prevSnapshot.ordinal === snapshot.ordinal && prevSnapshot.width === snapshot.width && prevSnapshot.height === snapshot.height; if (sameSnapshot) { @@ -222,11 +379,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 +408,13 @@ const DesktopWorkspace = ({ commitSize(); - if (typeof ResizeObserver === 'undefined') { - window.addEventListener('resize', commitSize); - return () => { - window.removeEventListener('resize', commitSize); - }; - } - const observer = new ResizeObserver(commitSize); observer.observe(container); return () => observer.disconnect(); }, [engine]); const resolvePreviewDimensions = useCallback( - (doc) => { + (doc: DeskDocument | null): PreviewMetadataEntry | null => { if (!doc?.id) { return null; } @@ -283,7 +433,7 @@ const DesktopWorkspace = ({ if (!doc) { return; } - resolveDocumentAssetUrl(doc, 'preview', { + resolveDocumentAssetUrl(doc, 'thumbnail', { ensureAssetUrl, getAsset: getDocumentAsset, }); @@ -292,40 +442,33 @@ const DesktopWorkspace = ({ const requestCanvasFocus = useCallback(() => { const canvas = containerRef.current; - if (!canvas || typeof canvas.focus !== 'function') { + if (!canvas?.focus) { return; } const focusTarget = () => { try { canvas.focus({ preventScroll: true }); - } catch (error) { + } catch (error: unknown) { if (DEBUG_FOCUS) { void error; } } }; - if (typeof window === 'undefined') { - focusTarget(); + const raf = window.requestAnimationFrame; + if (raf) { + raf(() => focusTarget()); return; } - - if (typeof window.requestAnimationFrame === 'function') { - window.requestAnimationFrame(() => { - focusTarget(); - }); - } else { - setTimeout(() => { - focusTarget(); - }, 0); - } + setTimeout(() => { + focusTarget(); + }, 0); }, []); const tagInteractions = useDeskTagInteractions({ engine, onAssignTagToDocument, - onRemoveTagFromDocument, requestCanvasFocus, }); @@ -345,7 +488,7 @@ const DesktopWorkspace = ({ useEffect(() => { const current = docSizeMapRef.current; - const next = new Map(current); + const next = new Map(current); const itemKeys = new Set(items.filter((doc) => doc?.id != null).map((doc) => String(doc.id))); let changed = false; @@ -392,30 +535,77 @@ const DesktopWorkspace = ({ } }, [items, previewMetadata]); - const overlayDisplay = useMemo(() => { + useEffect(() => { + let cancelled = false; if (!overlayDocId) { - return null; + setOverlaySource(null); + return () => { + cancelled = true; + }; } - const snapshot = previewSnapshots.get(overlayDocId); - if (!snapshot || !snapshot.url) { - return null; + + const doc = documentLookup.get(overlayDocId) || null; + const docIdentifier = doc?.id ?? null; + if (!docIdentifier || !doc) { + setOverlaySource(null); + return () => { + cancelled = true; + }; } - const doc = documentLookup.get(overlayDocId); - const alt = snapshot.alt || doc?.title; - return { - url: snapshot.url, - alt, - canGoPrev: snapshot.canGoPrev, - canGoNext: snapshot.canGoNext, - goPrev: snapshot.goPrev, - goNext: snapshot.goNext, + + const docContentType = doc?.content_type ?? null; + const versionContentType = (doc?.current_version as { version?: { content_type?: string | null } } | null)?.version?.content_type ?? null; + + const applyEntry = (entry?: DocumentLinkLike | null) => { + if (!entry?.url) { + setOverlaySource(null); + return; + } + setOverlaySource({ + url: entry.url, + alt: doc.title as string | undefined, + contentType: entry.contentType || docContentType || versionContentType || undefined, + }); }; - }, [overlayDocId, previewSnapshots, documentLookup]); + + const cachedEntry = documentLinkMap?.get(docIdentifier) || null; + if (cachedEntry?.url) { + applyEntry(cachedEntry); + return () => { + cancelled = true; + }; + } + + if (!ensureDownloadUrl) { + setOverlaySource(null); + return () => { + cancelled = true; + }; + } + + ensureDownloadUrl(docIdentifier) + .then((entry) => { + if (cancelled) { + return; + } + applyEntry(entry); + }) + .catch(() => { + if (!cancelled) { + setOverlaySource(null); + } + }); + + return () => { + cancelled = true; + }; + }, [overlayDocId, documentLookup, documentLinkMap, ensureDownloadUrl]); const closeOverlay = useCallback(() => { setOverlayDocId(null); setOverlayOriginRect(null); setOverlayOriginTransform(null); + setOverlaySource(null); }, []); useEffect(() => { @@ -427,7 +617,7 @@ const DesktopWorkspace = ({ }, [overlayDocId, documentLookup]); const resolveBaseMetrics = useCallback( - (doc, cardWidth, cardHeight) => { + (doc: DeskDocument | null, cardWidth: number, cardHeight: number) => { const previewDims = doc ? resolvePreviewDimensions(doc) : null; if (previewDims?.width && previewDims?.height) { const baseWidth = Math.max(previewDims.width, cardWidth); @@ -457,22 +647,39 @@ const DesktopWorkspace = ({ } }, [draggingId, items, setDraggingId]); + const overlayDisplay = useMemo(() => { + if (!overlaySource) { + return null; + } + return overlaySource; + }, [overlaySource]); + + const overlayDocument = useMemo(() => { + if (!overlayDocId) { + return null; + } + const baseDoc = documentLookup.get(String(overlayDocId)) || null; + if (baseDoc && overlayDisplay?.url) { + return { ...baseDoc, documentLink: overlayDisplay }; + } + return baseDoc; + }, [documentLookup, overlayDisplay, overlayDocId]); + const openOverlayForDoc = useCallback( - (docId, originInfo = null) => { + (docId: Identifier | null, originInfo: OverlayOriginHint | null = null) => { if (!docId) { return; } const docKey = String(docId); - const snapshot = previewSnapshots.get(docKey); - if (!snapshot || !snapshot.url) { - return; - } const container = itemRefs.current.get(docKey); - const imageNode = container?.querySelector?.('.desk-item__card img'); - if (!container || !imageNode) { + if (!container) { + return; + } + const imageNode = container.querySelector('.desk-item__card img'); + const rect = (imageNode || container).getBoundingClientRect(); + if (!rect) { return; } - const rect = imageNode.getBoundingClientRect(); let originTransform = null; if (originInfo) { const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo; @@ -510,7 +717,6 @@ const DesktopWorkspace = ({ }, [ bringToFront, - previewSnapshots, itemRefs, setOverlayOriginTransform, ensureDocumentSize, @@ -520,7 +726,7 @@ const DesktopWorkspace = ({ ], ); - const dragSettings = useMemo( + const dragSettings = useMemo( () => ({ canvasPadding: DESK_CANVAS_PADDING, defaultCanvasWidth: DESK_DEFAULT_CANVAS_WIDTH, @@ -530,7 +736,7 @@ const DesktopWorkspace = ({ [], ); - const viewProps = useMemo( + const viewProps = useMemo( () => ({ engine, items, @@ -548,7 +754,6 @@ const DesktopWorkspace = ({ tagDropTargetId, pendingTagDocId, pendingRemovalTag, - onDocumentOpen, ensureAssetUrl, getDocumentAsset, handleNavigatorSnapshot, @@ -565,13 +770,12 @@ const DesktopWorkspace = ({ closeOverlay, overlayOriginRect, overlayOriginTransform, + overlayDocument, onEntryPointer, onDocumentStackSelect, onPromoteSelection, selectedDocumentIds, onClearSelection, - detailPanelOpen, - onCloseDetailPanel, documentLookup, resolveBaseMetrics, bringToFront, @@ -613,8 +817,6 @@ const DesktopWorkspace = ({ layoutRef, layoutSnapshot, onClearSelection, - onCloseDetailPanel, - onDocumentOpen, onDocumentStackSelect, onEntryPointer, onPromoteSelection, @@ -622,6 +824,7 @@ const DesktopWorkspace = ({ overlayDisplay, overlayOriginRect, overlayOriginTransform, + overlayDocument, documentLookup, pendingRemovalTag, pendingTagDocId, @@ -629,7 +832,6 @@ const DesktopWorkspace = ({ resolveBaseMetrics, setDraggingId, selectedDocumentIds, - detailPanelOpen, onInspectDocument, markLayoutDirty, tagDropTargetId, @@ -639,7 +841,7 @@ const DesktopWorkspace = ({ return ; }; -const DesktopWorkspaceView = ({ +function DesktopWorkspaceView({ engine, items, containerRef, @@ -655,7 +857,6 @@ const DesktopWorkspaceView = ({ tagDropTargetId, pendingTagDocId, pendingRemovalTag, - onDocumentOpen, ensureAssetUrl, getDocumentAsset, handleNavigatorSnapshot, @@ -672,13 +873,12 @@ const DesktopWorkspaceView = ({ closeOverlay, overlayOriginRect, overlayOriginTransform, + overlayDocument, onEntryPointer, onDocumentStackSelect, onPromoteSelection, selectedDocumentIds, onClearSelection, - detailPanelOpen, - onCloseDetailPanel, documentLookup, resolveBaseMetrics, bringToFront, @@ -690,7 +890,7 @@ const DesktopWorkspaceView = ({ onInspectDocument, markLayoutDirty, dragTransformsRef, -}) => { +}: DesktopWorkspaceViewProps) { const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag({ engine, @@ -711,7 +911,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, @@ -726,12 +931,14 @@ const DesktopWorkspaceView = ({ onEntryPointer, onDocumentStackSelect, onPromoteSelection, - onDocumentOpen, + onInspectDocument, selectedDocumentIds, - detailPanelOpen, - onCloseDetailPanel, openOverlayForDoc, - }); + }) as { + getCardPointerHandlers: (doc: DeskDocument) => React.HTMLAttributes; + handleShellKeyDown: React.KeyboardEventHandler; + focusShell: () => void; + }; useEffect(() => { focusShell(); @@ -743,21 +950,13 @@ const DesktopWorkspaceView = ({ } }, [focusShell, selectedDocumentIds.length]); - useEffect(() => { - if (!detailPanelOpen) { - focusShell(); - } - }, [detailPanelOpen, focusShell]); - - - - const allSizesReady = items.every((doc) => ensureDocumentSize(doc)); + const allSizesReady = items.every((doc) => Boolean(ensureDocumentSize(doc))); return ( <>
{ - if (event.target === event.currentTarget && typeof onClearSelection === 'function') { - onClearSelection(); + if (event.target === event.currentTarget) { + onClearSelection?.(); } focusShell(); }} @@ -771,8 +970,8 @@ const DesktopWorkspaceView = ({ onDragLeave={handleCanvasDragLeave} onDrop={handleCanvasDrop} onPointerDown={(event) => { - if (event.target === event.currentTarget && typeof onClearSelection === 'function') { - onClearSelection(); + if (event.target === event.currentTarget) { + onClearSelection?.(); } focusShell(); }} @@ -786,7 +985,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; @@ -803,10 +1002,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), @@ -822,16 +1023,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; } @@ -844,7 +1048,7 @@ const DesktopWorkspaceView = ({ return ( ); -}; +} export default DesktopWorkspace; diff --git a/frontend/src/desktop/createDesktopSurface.js b/frontend/src/desktop/createDesktopSurface.js deleted file mode 100644 index 2b1dd9a..0000000 --- a/frontend/src/desktop/createDesktopSurface.js +++ /dev/null @@ -1,125 +0,0 @@ -import React 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'; - -const createDesktopSurface = ({ - workspaceProps, - renderSidebarToggle, - parentBreadcrumb, - onNavigateParent, - detailProps = null, - detailOpen = false, -}) => { - if (!workspaceProps) { - return null; - } - - const { - currentFolderName, - searchResults, - onRefresh, - viewMode, - onViewModeChange, - selectedDocumentIds, - selectedFolderIds, - onDeleteSelection, - onClearSelection, - tags, - correspondents, - documentLookup, - tagLookupById, - onBulkTagAdd, - onBulkTagRemove, - onBulkCorrespondentAdd, - onBulkCorrespondentRemove, - onBulkReanalyze, - folderOptions, - onMoveDocumentsToFolder, - searchIncludeDescendants, - onToggleSearchIncludeDescendants, - } = workspaceProps; - - const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; - const subtitle = Array.isArray(searchResults) - ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` - : null; - - const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; - const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; - const selectionCount = documentSelectionCount + folderSelectionCount; - const actions = createDocumentsTableHeaderActions({ - viewMode: viewMode || 'desk', - onViewModeChange, - onRefresh, - includeDescendants: searchIncludeDescendants, - onToggleIncludeDescendants: onToggleSearchIncludeDescendants, - }); - - const floatingActions = selectionCount > 0 - ? ( - - ) - : null; - - const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; - const detail = detailOpen && detailProps - ? (() => { - const { onClose, onOpenPreview, tags: tagOptions, ...restDetailProps } = detailProps; - return ( - - ); - })() - : null; - const surfaceConfig = createWorkspaceSurfaceConfig({ - key: 'workspace', - variant: 'workspace', - title, - subtitle, - sidebarToggle, - parentBreadcrumb, - onNavigateParent, - actions, - breadcrumbs: workspaceProps?.breadcrumbs || null, - selectionLabel: null, - floatingActions, - content: ( - - ), - detail, - }); - - return { - ...surfaceConfig, - supportsDetail: Boolean(detailProps), - }; -}; - -export default createDesktopSurface; diff --git a/frontend/src/desktop/db.js b/frontend/src/desktop/db.ts similarity index 52% rename from frontend/src/desktop/db.js rename to frontend/src/desktop/db.ts index 2e571ce..85ca546 100644 --- a/frontend/src/desktop/db.js +++ b/frontend/src/desktop/db.ts @@ -2,20 +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) => { - if (typeof indexedDB === 'undefined') { - reject(new Error('IndexedDB not available')); - return; - } - - const request = indexedDB.open(DB_NAME, DB_VERSION); + const request = window.indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; @@ -38,51 +33,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); @@ -100,13 +100,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 []; } @@ -122,7 +133,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; } @@ -143,7 +154,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) { @@ -151,7 +162,7 @@ export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => { } }; -export const deleteTenantLayouts = async (tenantId) => { +export const deleteTenantLayouts = async (tenantId?: string | number) => { if (!tenantId) { return; } @@ -168,15 +179,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 e803064..5aa7eeb 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,18 @@ export const preventAll = (event) => { } }; -export const safeInvoke = (fn, ...args) => (typeof fn === 'function' ? fn(...args) : undefined); +type AnyFn = (...args: unknown[]) => unknown; -export const getPointerPosition = (event, { fallbackToPage = true } = {}) => { +export const safeInvoke = ( + fn: Fn | null, + ...args: Parameters +): ReturnType | undefined => + (fn ? (fn(...args) as ReturnType) : 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 6078c21..3133fff 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; +type EnsureAssetUrl = (docId: string | number, asset: AssetLike, options?: { force?: boolean }) => Promise; + +const usePreviewMetadata = ( + documents: DocumentLike[] | null, + getDocumentAsset?: GetDocumentAsset, + ensureAssetUrl?: EnsureAssetUrl, +) => { + const [metadataMap, setMetadataMap] = useState>(() => new Map()); useEffect(() => { let cancelled = false; @@ -14,20 +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) => - (typeof getDocumentAsset === 'function' ? 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) => Number.isFinite(Number(meta?.width)) && Number.isFinite(Number(meta?.height)) && Number(meta.width) > 0 && @@ -35,7 +58,7 @@ const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { try { - const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 }); + const ensured = await ensureAssetUrl(doc.id, asset, { force: true }); if (ensured) { asset = ensured; view = createAssetView(asset); 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 60% rename from frontend/src/desktop/pointer/pointerUtils.js rename to frontend/src/desktop/pointer/pointerUtils.ts index 160d78a..ef4e7de 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,12 +61,12 @@ export const createPointerIntent = ({ pointerButton, pointerType, stackHits, -}) => { +}: PointerIntentArgs): PointerIntent => { const alreadySelected = selectedDocumentIds.includes(doc.id); const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; - let clickAction = CLICK_ACTIONS.none; - let dragAction = DRAG_ACTIONS.none; + let clickAction: ClickAction = CLICK_ACTIONS.none; + let dragAction: DragAction = DRAG_ACTIONS.none; if (metaKey) { clickAction = CLICK_ACTIONS.addStack; @@ -47,8 +79,8 @@ export const createPointerIntent = ({ dragAction = DRAG_ACTIONS.dragSelectSingle; } - const stackList = Array.isArray(stackHits) && stackHits.length > 0 - ? stackHits.slice() + const stackList: string[] = Array.isArray(stackHits) && stackHits.length > 0 + ? stackHits.map((value) => String(value)) : [String(doc.id)]; const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null; @@ -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,14 +150,19 @@ 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; } - const stackCopy = Array.isArray(stackDocIds) && stackDocIds.length > 0 - ? stackDocIds.slice() - : [intent.docId]; + const stackCopy: string[] = Array.isArray(stackDocIds) && stackDocIds.length > 0 + ? stackDocIds.map((value) => String(value)) + : [String(intent.docId)]; safeInvoke(onDocumentStackSelect, stackCopy, syntheticEvent, { replace: true }); diff --git a/frontend/src/desktop/pointer/useDeskPointer.js b/frontend/src/desktop/pointer/useDeskPointer.js index 855f955..5c87d61 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', @@ -35,10 +35,8 @@ export const useDeskPointer = ({ onEntryPointer, onDocumentStackSelect, onPromoteSelection, - onDocumentOpen, + onInspectDocument, selectedDocumentIds, - detailPanelOpen, - onCloseDetailPanel, openOverlayForDoc = null, }) => { const pointerIntentRef = useRef(null); @@ -183,9 +181,6 @@ export const useDeskPointer = ({ } longPressActiveRef.current = true; - if (typeof window === 'undefined') { - return; - } longPressTimerRef.current = window.setTimeout(() => { if (!longPressActiveRef.current || pointerMovedRef.current) { @@ -229,8 +224,8 @@ export const useDeskPointer = ({ pointerMovedRef.current = false; resetLongPressState(); - const pointerButton = typeof event.button === 'number' ? event.button : 0; - const pointerType = typeof event.pointerType === 'string' ? event.pointerType : ''; + const pointerButton = Number.isFinite(event?.button) ? event.button : 0; + const pointerType = String(event?.pointerType ?? ''); const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey; const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); @@ -322,18 +317,15 @@ export const useDeskPointer = ({ && !pointerState.longPressTriggered && pointerState.docId === doc.id ) { - const expectedButton = typeof pointerState.pointerButton === 'number' + const expectedButton = Number.isFinite(pointerState?.pointerButton) ? pointerState.pointerButton : 0; - const releasedButton = typeof event.button === 'number' - ? event.button - : expectedButton; + const releasedButton = Number.isFinite(event?.button) ? event.button : expectedButton; const isPrimaryRelease = expectedButton === 0 && releasedButton === 0; const stillSelected = Array.isArray(selectedDocumentIds) && selectedDocumentIds.includes(doc.id); if (isPrimaryRelease && stillSelected) { - const useSelection = pointerState.selectedAtDown && pointerState.selectionCountAtDown > 0; - safeInvoke(onDocumentOpen, doc.id, { useSelection }); + safeInvoke(onInspectDocument, doc.id); } } } @@ -343,7 +335,7 @@ export const useDeskPointer = ({ }, [ handlePointerUp, - onDocumentOpen, + onInspectDocument, onDocumentStackSelect, onEntryPointer, resetLongPressState, @@ -401,11 +393,7 @@ export const useDeskPointer = ({ } } - if ( - typeof openOverlayForDoc === 'function' - && Array.isArray(selectedDocumentIds) - && selectedDocumentIds.length > 0 - ) { + if (openOverlayForDoc && Array.isArray(selectedDocumentIds) && selectedDocumentIds.length > 0) { event.preventDefault(); const targetId = selectedDocumentIds[selectedDocumentIds.length - 1]; if (targetId) { @@ -414,12 +402,8 @@ export const useDeskPointer = ({ return; } - if (detailPanelOpen) { - event.preventDefault(); - safeInvoke(onCloseDetailPanel); - } }, - [detailPanelOpen, onCloseDetailPanel, openOverlayForDoc, selectedDocumentIds], + [openOverlayForDoc, selectedDocumentIds], ); return { @@ -427,9 +411,7 @@ export const useDeskPointer = ({ handleShellKeyDown, focusShell: () => { const shell = containerRef.current; - if (shell && typeof shell.focus === 'function') { - shell.focus({ preventScroll: true }); - } + shell?.focus?.({ preventScroll: true }); }, }; }; diff --git a/frontend/src/desktop/tags/useDeskTagInteractions.js b/frontend/src/desktop/tags/useDeskTagInteractions.js index b1c4e7b..cc064b8 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, @@ -11,7 +11,6 @@ import { } from '../../documents/tagTransfer'; const TAG_REMOVE_DISTANCE = 160; -const DEBUG_DROP = false; const createDragPreview = (node, clientX, clientY) => { if (!(node instanceof HTMLElement)) { @@ -42,147 +41,22 @@ const cleanupPreview = (previewNode) => { export const useDeskTagInteractions = ({ engine, onAssignTagToDocument, - onRemoveTagFromDocument, requestCanvasFocus, }) => { - const draggingTagRef = useRef(null); - const pendingDocTagDragRef = useRef(null); - const removalCursorActiveRef = useRef(false); - - const updateRemovalCursor = useCallback((active) => { - if (typeof document === 'undefined') { - return; - } - if (removalCursorActiveRef.current === active) { - return; - } - const body = document.body; - if (!body) { - return; - } - removalCursorActiveRef.current = active; - if (active) { - body.classList.add('desk-cursor-remove'); - } else { - body.classList.remove('desk-cursor-remove'); - } - }, []); - - useEffect( - () => () => { - updateRemovalCursor(false); - }, - [updateRemovalCursor], - ); const isTagTransfer = useCallback((event) => isTagTransferEvent(event), []); - const handleTagDragEnd = useCallback(() => { - updateRemovalCursor(false); - engine.setTagDropTargetId(null); - }, [engine, updateRemovalCursor]); - - const finalizeTagDrag = useCallback( - (dropEffect = 'none') => { - const state = draggingTagRef.current; - if (!state) { - updateRemovalCursor(false); - return; - } - - draggingTagRef.current = null; - - const node = state.element; - const showNode = () => { - if (node instanceof HTMLElement) { - node.classList.remove('is-drag-hidden'); - } - }; - const scheduleShowNode = () => { - if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { - window.requestAnimationFrame(showNode); - } else { - setTimeout(showNode, 0); - } - }; - - cleanupPreview(state.previewClone); - - const shouldRemove = - !state.dropHandled - && dropEffect === 'none' - && state.sourceDocId - && (state.distance || 0) >= TAG_REMOVE_DISTANCE; - - if (!shouldRemove) { - scheduleShowNode(); - updateRemovalCursor(false); - return; - } - - updateRemovalCursor(false); - engine.setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId }); - const removePromise = safeInvoke(onRemoveTagFromDocument, state.sourceDocId, state.tagId); - if (!removePromise || typeof removePromise.then !== 'function') { - scheduleShowNode(); - engine.setPendingRemovalTag(null); - updateRemovalCursor(false); - return; - } - void (async () => { - try { - await removePromise; - void DEBUG_DROP; - } catch (error) { - console.error('Failed to remove tag after drag', error); - scheduleShowNode(); - } finally { - engine.setPendingRemovalTag(null); - } - })(); - }, - [engine, onRemoveTagFromDocument, updateRemovalCursor], - ); - - const handleDocTagPointerDown = useCallback((event, doc, tag) => { - if (!doc || !tag) { - pendingDocTagDragRef.current = null; - return; - } - const { x: startX, y: startY } = getPointerPosition(event); - pendingDocTagDragRef.current = { - docId: doc.id, - tagId: tag.id, - startX, - startY, - }; - updateRemovalCursor(false); - }, [updateRemovalCursor]); - - const markActiveTagDropHandled = useCallback((tagId, sourceDocId = null) => { - const state = draggingTagRef.current; - if (!state) { - return; - } - if (state.tagId !== tagId) { - return; - } - if (sourceDocId && state.sourceDocId !== sourceDocId) { - return; - } - state.dropHandled = true; - }, []); + const handleDocTagPointerDown = useCallback(() => { + engine.setPendingRemovalTag(null); + }, [engine]); const runTagHoverTransition = useCallback( - (event, docId, { applyTarget = false, applyPending = false, updateCursor = true } = {}) => { + (event, docId, { applyTarget = false, applyPending = false } = {}) => { if (!isTagTransfer(event)) { return; } preventAll(event); - if (updateCursor) { - updateRemovalCursor(false); - } const stringId = docId != null ? String(docId) : null; if (applyTarget) { engine.setTagDropTargetId(stringId); @@ -191,7 +65,7 @@ export const useDeskTagInteractions = ({ engine.setPendingTagDocId(stringId); } }, - [engine, isTagTransfer, updateRemovalCursor], + [engine, isTagTransfer], ); const handleTagDragEnterDoc = useCallback( @@ -240,7 +114,7 @@ export const useDeskTagInteractions = ({ if (!payload || !payload.id) { return; } - markActiveTagDropHandled(payload.id, payload.sourceDocId); + engine.setPendingRemovalTag(null); if (payload.sourceDocId === doc.id) { return; @@ -254,7 +128,7 @@ export const useDeskTagInteractions = ({ sourceDocId: payload.sourceDocId ?? null, }); }, - [engine, isTagTransfer, markActiveTagDropHandled, onAssignTagToDocument, requestCanvasFocus], + [engine, isTagTransfer, onAssignTagToDocument, requestCanvasFocus], ); const handleDocTagDragStart = useCallback( @@ -267,7 +141,7 @@ export const useDeskTagInteractions = ({ const { x: pointerX, y: pointerY } = getPointerPosition(event, { fallbackToPage: false }); const { clone, offsetX, offsetY } = createDragPreview(event.currentTarget, pointerX, pointerY) || {}; - if (clone && typeof event.dataTransfer.setDragImage === 'function') { + if (clone) { event.dataTransfer.setDragImage(clone, offsetX || 0, offsetY || 0); } @@ -284,12 +158,11 @@ export const useDeskTagInteractions = ({ initialX: pointerX, initialY: pointerY, distance: 0, - dropHandled: false, }; - updateRemovalCursor(false); + engine.setPendingRemovalTag(null); }, - [updateRemovalCursor], + [engine], ); const handleDocTagDrag = useCallback((event) => { @@ -302,35 +175,36 @@ export const useDeskTagInteractions = ({ const dy = y - (state.initialY || 0); state.distance = Math.sqrt(dx * dx + dy * dy); if (state.distance >= TAG_REMOVE_DISTANCE) { - updateRemovalCursor(true); + engine.setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId }); } else { - updateRemovalCursor(false); + engine.setPendingRemovalTag(null); } - }, [updateRemovalCursor]); + }, [engine]); const handleDocTagDragEnd = useCallback( - (event) => { - finalizeTagDrag(event?.dataTransfer?.dropEffect || 'none'); + () => { const state = draggingTagRef.current; - if (!state) { - return; + if (state) { + const element = state.element; + if (element) { + element.classList.remove('is-drag-hidden'); + } + cleanupPreview(state.previewClone); } - const element = state.element; - if (element) { - element.classList.remove('is-drag-hidden'); - } - cleanupPreview(state.previewClone); draggingTagRef.current = null; + engine.setPendingRemovalTag(null); + engine.setTagDropTargetId(null); + engine.setPendingTagDocId(null); }, - [finalizeTagDrag], + [engine], ); useEffect(() => { return () => { draggingTagRef.current = null; - pendingDocTagDragRef.current = null; + engine.setPendingRemovalTag(null); }; - }, []); + }, [engine]); return { handleTagDragEnterDoc, @@ -341,8 +215,6 @@ export const useDeskTagInteractions = ({ handleDocTagDragStart, handleDocTagDrag, handleDocTagDragEnd, - handleTagDragEnd, - markActiveTagDropHandled, handleCanvasDragOver, handleCanvasDragLeave, handleCanvasDrop, diff --git a/frontend/src/desktop/useDeskWorkspaceProps.js b/frontend/src/desktop/useDeskWorkspaceProps.js deleted file mode 100644 index 8d8f634..0000000 --- a/frontend/src/desktop/useDeskWorkspaceProps.js +++ /dev/null @@ -1,214 +0,0 @@ -import { useCallback, useMemo } from 'react'; - -const useDeskWorkspaceProps = ({ - documents, - searchResults, - breadcrumbs, - currentFolderName, - documentsViewMode, - handleDocumentsViewModeChange, - handleDeskExit, - refreshCurrentFolder, - inspectDocument, - handleEntryPointerCore, - promoteSelectionOrder, - currentTenantId, - selectedDocumentIds, - selectedFolderIds, - clearDocumentSelection, - detailPanelOpen, - handleDetailPanelClose, - resolveThumbnailUrlForDoc, - handleDocumentTagDrop, - handleTagRemove, - ensureAssetUrl, - getDocumentAsset, - activeTagFilters, - handleDeleteSelection, - tags, - correspondents, - documentLookup, - tagLookupById, - handleBulkTagAddFromDetail, - handleBulkTagRemoveFromDetail, - handleBulkCorrespondentAdd, - handleBulkCorrespondentRemove, - handleBulkSelectionReanalyze, - folderOptions, - moveDocumentsToFolder, - searchIncludeDescendants, - toggleSearchIncludeDescendants, - selectedEntries, - selectionAnchorRef, - applySelection, - resolveDocumentRowKey, - showingSearchResults, - searchQuery, - activeCorrespondentFilters, - selectedFolder, - openDetailPanel, -}) => { - const handleDeskDocumentStackSelect = useCallback( - (docIds) => { - if (!Array.isArray(docIds) || docIds.length === 0) { - return; - } - - const rowKeys = docIds - .map((id) => resolveDocumentRowKey(id)) - .filter(Boolean); - - if (!rowKeys.length) { - return; - } - - const nextKeys = [...selectedEntries]; - rowKeys.forEach((key) => { - if (!nextKeys.includes(key)) { - nextKeys.push(key); - } - }); - - const anchor = rowKeys[0] - || selectionAnchorRef.current - || nextKeys[nextKeys.length - 1]; - - applySelection(nextKeys, { - anchor, - interactedKeys: rowKeys, - }); - }, - [applySelection, resolveDocumentRowKey, selectedEntries, selectionAnchorRef], - ); - - const handleDeskDocumentOpen = useCallback( - (docId, { useSelection = false } = {}) => { - const selectionDocIds = Array.isArray(selectedDocumentIds) - ? selectedDocumentIds - : []; - let targetIds = []; - - if ((useSelection || selectionDocIds.includes(docId)) && selectionDocIds.length) { - targetIds = selectionDocIds; - } else if (selectionDocIds.length) { - targetIds = selectionDocIds; - } else if (docId) { - targetIds = [docId]; - } - - if (!targetIds.length) { - return; - } - - openDetailPanel({ documentIds: targetIds }); - }, - [openDetailPanel, selectedDocumentIds], - ); - - const deskViewId = useMemo(() => { - if (showingSearchResults) { - const trimmedQuery = searchQuery.trim(); - const tagsKey = [...activeTagFilters].sort().join(','); - const correspondentsKey = [...activeCorrespondentFilters].sort().join(','); - return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`; - } - - const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root'; - return `folder:${folderKey}`; - }, [ - showingSearchResults, - searchQuery, - activeTagFilters, - activeCorrespondentFilters, - selectedFolder, - ]); - - return useMemo( - () => ({ - documents, - searchResults, - breadcrumbs, - currentFolderName, - viewMode: documentsViewMode, - onViewModeChange: handleDocumentsViewModeChange, - onExit: handleDeskExit, - onRefresh: refreshCurrentFolder, - onDocumentOpen: handleDeskDocumentOpen, - onInspectDocument: inspectDocument, - onEntryPointer: handleEntryPointerCore, - onDocumentStackSelect: handleDeskDocumentStackSelect, - onPromoteSelection: promoteSelectionOrder, - tenantId: currentTenantId, - viewId: deskViewId, - selectedDocumentIds, - selectedFolderIds, - onClearSelection: clearDocumentSelection, - detailPanelOpen, - onCloseDetailPanel: handleDetailPanelClose, - resolveThumbnailUrl: resolveThumbnailUrlForDoc, - onAssignTagToDocument: handleDocumentTagDrop, - onRemoveTagFromDocument: handleTagRemove, - ensureAssetUrl, - getDocumentAsset, - activeTagIds: activeTagFilters, - onDeleteSelection: handleDeleteSelection, - tags, - correspondents, - documentLookup, - tagLookupById, - onBulkTagAdd: handleBulkTagAddFromDetail, - onBulkTagRemove: handleBulkTagRemoveFromDetail, - onBulkCorrespondentAdd: handleBulkCorrespondentAdd, - onBulkCorrespondentRemove: handleBulkCorrespondentRemove, - onBulkReanalyze: handleBulkSelectionReanalyze, - folderOptions, - onMoveDocumentsToFolder: moveDocumentsToFolder, - searchIncludeDescendants, - onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants, - }), - [ - documents, - searchResults, - breadcrumbs, - currentFolderName, - documentsViewMode, - handleDocumentsViewModeChange, - handleDeskExit, - refreshCurrentFolder, - handleDeskDocumentOpen, - inspectDocument, - handleEntryPointerCore, - handleDeskDocumentStackSelect, - promoteSelectionOrder, - currentTenantId, - deskViewId, - selectedDocumentIds, - selectedFolderIds, - clearDocumentSelection, - detailPanelOpen, - handleDetailPanelClose, - resolveThumbnailUrlForDoc, - handleDocumentTagDrop, - handleTagRemove, - ensureAssetUrl, - getDocumentAsset, - activeTagFilters, - handleDeleteSelection, - tags, - correspondents, - documentLookup, - tagLookupById, - handleBulkTagAddFromDetail, - handleBulkTagRemoveFromDetail, - handleBulkCorrespondentAdd, - handleBulkCorrespondentRemove, - handleBulkSelectionReanalyze, - folderOptions, - moveDocumentsToFolder, - searchIncludeDescendants, - toggleSearchIncludeDescendants, - ], - ); -}; - -export default useDeskWorkspaceProps; diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.ts similarity index 65% rename from frontend/src/desktop/useDocumentDrag.js rename to frontend/src/desktop/useDocumentDrag.ts index 354e20c..b031b31 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.ts @@ -1,22 +1,202 @@ -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, + MAX_ANGULAR_VELOCITY, + MAX_DYNAMIC_ROTATION, + CARD_BASE_WEIGHT_GRAMS, + CARD_PAGE_WEIGHT_GRAMS, + applyDomTransform, + type WorkspaceEngine, +} from './workspaceEngine'; + +type Identifier = string | number; + +interface DocumentLike { + id?: Identifier | null; + title?: string; + current_version?: { + metadata?: { page_count?: number | string | null } | null; + } | null; + metadata?: { page_count?: number | string | null } | null; + [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; + initialRotation?: number; +} + +type EnsureDocumentSizeFn = (doc: DocumentLike | null) => DocumentSizeInfo | null; + +type ResolveBaseMetricsFn = ( + doc: DocumentLike | null, + 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) => void; + setDraggingId: (docKey: string | null) => void; + canvasSize: { width: number; height: number }; + openOverlayForDoc?: ( + docId: Identifier | null, + originInfo?: { rotation: number; scale: number; width: number; height: number }, + ) => void; + recalcVisibleDocIds: () => void; + settings?: DragSettings; + containerRef?: RefObject; + onInspectDocument?: (docId: Identifier | null, 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; + massGrams: number; + pointerRadiusScale: number; + lastPointerCanvasX: number; + lastPointerCanvasY: number; +} + +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) => { - if (typeof Element === 'undefined' || !event) { +const getDocumentPageCount = (doc?: DocumentLike | null): number | null => { + const raw = doc?.current_version?.metadata?.page_count ?? doc?.metadata?.page_count; + if (raw == null) { return null; } - const candidate = event.target || (event.nativeEvent ? event.nativeEvent.target : null); + const value = Number(raw); + return Number.isFinite(value) ? value : null; +}; + +const computeDocumentMassGrams = (doc?: DocumentLike | null): number => { + const pages = Math.max(1, Math.round(getDocumentPageCount(doc) ?? 1)); + return CARD_BASE_WEIGHT_GRAMS + pages * CARD_PAGE_WEIGHT_GRAMS; +}; + +const getEventTargetElement = (event?: PointerEventLike | null): Element | null => { + if (!event) { + return 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 Element ? candidate : null; }; -const useDocumentDrag = (options = {}) => { +const useDocumentDrag = (options: UseDocumentDragOptions) => { const { engine, layoutRef, @@ -31,13 +211,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, @@ -52,25 +235,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; } - if (typeof onInspectDocument === 'function') { - onInspectDocument(data.docId, event); - } + onInspectDocument?.(data.docId, event); }, }); - const dragStateRef = useRef(null); + const dragStateRef = useRef(null); - const setDragTransform = useCallback((docKey, transform) => { + const setDragTransform = useCallback((docKey: Identifier | null, transform: DragTransform | null) => { if (!docKey) { return; } @@ -78,24 +259,30 @@ 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(() => { const map = dragTransformsRef?.current; - if (!map || typeof map.clear !== 'function') { + if (!map?.clear) { return; } 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); @@ -114,11 +301,11 @@ 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; - if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') { + if (capturedTarget?.releasePointerCapture) { try { capturedTarget.releasePointerCapture(pointerId); } catch (error) { @@ -139,9 +326,9 @@ const useDocumentDrag = (options = {}) => { ); const handlePointerDown = useCallback( - (event, docIdInput, options = {}) => { + (event: PointerEventLike, docIdInput?: Identifier | null, options: PointerDownOptions = {}) => { const targetElement = getEventTargetElement(event); - if (targetElement && typeof targetElement.closest === 'function' && targetElement.closest('[data-desk-tag-chip="true"]')) { + if (targetElement?.closest && targetElement.closest('[data-desk-tag-chip="true"]')) { return; } preventAll(event); @@ -158,22 +345,24 @@ const useDocumentDrag = (options = {}) => { if (!doc) { return; } + const massGrams = computeDocumentMassGrams(doc); const stackDocIdsOptionRaw = options?.stackDocIds; 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); - const pointerModifierActive = typeof options?.modifierActive === 'boolean' - ? options.modifierActive - : Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); + const pointerModifierActive = + 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) { @@ -195,9 +384,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); @@ -227,8 +414,8 @@ const useDocumentDrag = (options = {}) => { const entry = layoutRef.current.get(docKey) || null; const defaultCenterX = canvasPadding + docWidth / 2; const defaultCenterY = canvasPadding + docHeight / 2; - const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX; - const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY; + const centerX = Number.isFinite(entry?.centerX) ? entry.centerX : defaultCenterX; + const centerY = Number.isFinite(entry?.centerY) ? entry.centerY : defaultCenterY; const modifierPressed = pointerModifierActive; if (!modifierPressed) { @@ -255,7 +442,7 @@ const useDocumentDrag = (options = {}) => { } const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; - if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') { + if (capturedTarget?.setPointerCapture) { try { capturedTarget.setPointerCapture(event.pointerId); } catch (error) { @@ -265,7 +452,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; @@ -279,20 +466,19 @@ 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; const itemHeight = itemSize.height || docHeight; const itemEntry = layoutRef.current.get(id) || null; const itemCenterX = - typeof itemEntry?.centerX === 'number' ? itemEntry.centerX : canvasPadding + itemWidth / 2; + Number.isFinite(itemEntry?.centerX) ? itemEntry.centerX : canvasPadding + itemWidth / 2; const itemCenterY = - typeof itemEntry?.centerY === 'number' ? itemEntry.centerY : canvasPadding + itemHeight / 2; + Number.isFinite(itemEntry?.centerY) ? itemEntry.centerY : canvasPadding + itemHeight / 2; const baseOffsetX = itemCenterX - centerX; const baseOffsetY = itemCenterY - centerY; const initialRotation = itemEntry?.rotation ?? 0; - const targetRotation = initialRotation; return { docId: id, width: itemWidth, @@ -303,16 +489,16 @@ const useDocumentDrag = (options = {}) => { baseOffsetY, offsetX: baseOffsetX, offsetY: baseOffsetY, - initialRotation, + targetRotation: initialRotation, displayRotation: initialRotation, - targetRotation, - }; + + } satisfies DragGroupItemInternal; }); const eventTimestamp = - typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp) + (Number.isFinite(event?.timeStamp)) ? event.timeStamp - : typeof performance !== 'undefined' && typeof performance.now === 'function' + : performance?.now ? performance.now() : Date.now(); @@ -353,9 +539,16 @@ const useDocumentDrag = (options = {}) => { stackDocIds: hasStackSource ? stackDocIdsOption : null, stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource, stackReplace, - }; + massGrams, + pointerRadiusScale: 1, + lastPointerCanvasX: pointerCanvasX, + lastPointerCanvasY: pointerCanvasY, + } satisfies DragStateInternal; const state = dragStateRef.current; + if (!state) { + return; + } clearDragTransforms(); state.groupItems.forEach((item) => { @@ -415,7 +608,7 @@ const useDocumentDrag = (options = {}) => { ]); const handlePointerMove = useCallback( - (event) => { + (event: PointerEventLike) => { const state = dragStateRef.current; if (!state) { return; @@ -423,10 +616,66 @@ const useDocumentDrag = (options = {}) => { if (state.pointerId !== event.pointerId) { return; } - preventAll(event); + preventAll(event); + + const updatePointerAngularVelocity = ( + pointerCanvasX: number, + pointerCanvasY: number, + centerX: number, + centerY: number, + dtSeconds: number, + ) => { + if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) { + return; + } + const leverX = pointerCanvasX - centerX; + const leverY = pointerCanvasY - centerY; + if (!Number.isFinite(leverX) || !Number.isFinite(leverY)) { + return; + } + const prevCanvasX = Number.isFinite(state.lastPointerCanvasX) + ? state.lastPointerCanvasX + : pointerCanvasX; + const prevCanvasY = Number.isFinite(state.lastPointerCanvasY) + ? state.lastPointerCanvasY + : pointerCanvasY; + const velocityCanvasX = (pointerCanvasX - prevCanvasX) / dtSeconds; + const velocityCanvasY = (pointerCanvasY - prevCanvasY) / dtSeconds; + state.lastPointerCanvasX = pointerCanvasX; + state.lastPointerCanvasY = pointerCanvasY; + if (!Number.isFinite(velocityCanvasX) || !Number.isFinite(velocityCanvasY)) { + return; + } + const torque = leverX * velocityCanvasY - leverY * velocityCanvasX; + const influenceRadius = Math.max(state.width, state.height) / 2 || 1; + const radiusScale = clamp(Math.hypot(leverX, leverY) / influenceRadius, 0.2, 2.5); + state.pointerRadiusScale = radiusScale; + const torqueResponse = 0.0025 * radiusScale; + const angularVelocityDeg = clamp( + torque * torqueResponse, + -MAX_ANGULAR_VELOCITY, + MAX_ANGULAR_VELOCITY, + ); + const mass = Math.max(state.massGrams || CARD_BASE_WEIGHT_GRAMS, CARD_BASE_WEIGHT_GRAMS); + const massScale = Math.max(mass / CARD_BASE_WEIGHT_GRAMS, 1); + state.angularVelocity = angularVelocityDeg / massScale; + }; + + const applyDynamicRotation = (dtSeconds: number, dampingFactor = 0.94) => { + if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) { + return; + } + const radiusInfluence = clamp(state.pointerRadiusScale || 1, 0.3, 3); + const response = 1.1 * radiusInfluence; + let nextDynamic = state.dynamicRotation + state.angularVelocity * dtSeconds * response; + nextDynamic = clamp(nextDynamic, -MAX_DYNAMIC_ROTATION, MAX_DYNAMIC_ROTATION); + const adjustedDamping = Math.pow(dampingFactor, 1 / Math.max(radiusInfluence, 0.8)); + state.dynamicRotation = nextDynamic * adjustedDamping; + state.rotation = state.restRotation + state.dynamicRotation; + }; if (state.isGroup) { - const containerRect = containerRef?.current?.getBoundingClientRect?.(); + const containerRect = containerRef.current?.getBoundingClientRect?.(); if (containerRect) { state.containerRectLeft = containerRect.left; state.containerRectTop = containerRect.top; @@ -448,7 +697,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) { @@ -537,14 +788,30 @@ const useDocumentDrag = (options = {}) => { applyDomTransform(node, payload); }); - state.lastClientX = event.clientX; - state.lastClientY = event.clientY; - state.lastTimestamp = - typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp) + const currentTimestampGroup = + (Number.isFinite(event?.timeStamp)) ? event.timeStamp - : typeof performance !== 'undefined' && typeof performance.now === 'function' + : performance?.now ? performance.now() : Date.now(); + const previousTimestampGroup = state.lastTimestamp ?? currentTimestampGroup; + let dtGroup = (currentTimestampGroup - previousTimestampGroup) / 1000; + if (!Number.isFinite(dtGroup) || dtGroup <= 0) { + dtGroup = MIN_TIMESTEP; + } + dtGroup = clamp(dtGroup, MIN_TIMESTEP, MAX_TIMESTEP); + + state.lastClientX = event.clientX; + state.lastClientY = event.clientY; + state.lastTimestamp = currentTimestampGroup; + + updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, centerX, centerY, dtGroup); + applyDynamicRotation(dtGroup, 0.96); + state.groupItems.forEach((item) => { + if (item.docId === state.docKey) { + item.displayRotation = state.rotation ?? item.displayRotation ?? 0; + } + }); return; } @@ -560,7 +827,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; @@ -572,6 +839,35 @@ const useDocumentDrag = (options = {}) => { const pointerCanvasY = event.clientY - containerTop; const entry = layoutRef.current.get(state.docKey) || {}; + const previousCenterX = Number.isFinite(state.currentCenterX) + ? state.currentCenterX + : state.originCenterX; + const previousCenterY = Number.isFinite(state.currentCenterY) + ? state.currentCenterY + : state.originCenterY; + + const currentTimestamp = + (Number.isFinite(event?.timeStamp)) + ? event.timeStamp + : performance?.now + ? performance.now() + : Date.now(); + const previousTimestamp = state.lastTimestamp ?? currentTimestamp; + let dt = (currentTimestamp - previousTimestamp) / 1000; + if (!Number.isFinite(dt) || dt <= 0) { + dt = MIN_TIMESTEP; + } + dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP); + + const torqueCenterX = Number.isFinite(previousCenterX) ? previousCenterX : state.originCenterX; + const torqueCenterY = Number.isFinite(previousCenterY) ? previousCenterY : state.originCenterY; + updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, torqueCenterX, torqueCenterY, dt); + applyDynamicRotation(dt); + + state.lastClientX = event.clientX; + state.lastClientY = event.clientY; + state.lastTimestamp = currentTimestamp; + const rotationDeg = state.rotation ?? entry.rotation ?? 0; const rotationRad = (rotationDeg * Math.PI) / 180; const cosRot = Math.cos(rotationRad); @@ -581,13 +877,6 @@ const useDocumentDrag = (options = {}) => { const rotatedOffsetY = state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot; - const previousCenterX = Number.isFinite(state.currentCenterX) - ? state.currentCenterX - : state.originCenterX; - const previousCenterY = Number.isFinite(state.currentCenterY) - ? state.currentCenterY - : state.originCenterY; - const absCos = Math.abs(cosRot); const absSin = Math.abs(sinRot); const rotatedHalfWidth = absCos * halfWidth + absSin * halfHeight; @@ -662,23 +951,6 @@ const useDocumentDrag = (options = {}) => { const pointerInsideCard = Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight; - const currentTimestamp = - typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp) - ? event.timeStamp - : typeof performance !== 'undefined' && typeof performance.now === 'function' - ? performance.now() - : Date.now(); - const previousTimestamp = state.lastTimestamp ?? currentTimestamp; - let dt = (currentTimestamp - previousTimestamp) / 1000; - if (!Number.isFinite(dt) || dt <= 0) { - dt = MIN_TIMESTEP; - } - dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP); - - state.lastClientX = event.clientX; - state.lastClientY = event.clientY; - state.lastTimestamp = currentTimestamp; - const rotationForOffsetDeg = state.rotation || 0; const rotationForOffsetRad = (rotationForOffsetDeg * Math.PI) / 180; const cosInverse = Math.cos(-rotationForOffsetRad); @@ -711,7 +983,7 @@ const useDocumentDrag = (options = {}) => { ); const handlePointerUp = useCallback( - (event) => { + (event: PointerEventLike) => { const state = dragStateRef.current; if (!state || state.pointerId !== event.pointerId) { finishDrag(event.pointerId); @@ -728,14 +1000,18 @@ const useDocumentDrag = (options = {}) => { if (state.moved) { commitActiveDragTransforms([state.docKey]); - const inertiaState = { - restRotation: state.restRotation, - dynamicRotation: state.dynamicRotation, + const finalRotation = state.rotation ?? state.restRotation; + const inertiaState: EngineInertiaState = { + docId: state.docKey, + restRotation: finalRotation, + dynamicRotation: 0, angularVelocity: state.angularVelocity, - rotation: state.rotation, + rotation: finalRotation, width: state.width, height: state.height, dragScale: state.dragScale || 1, + lastTimestamp: state.lastTimestamp, + massGrams: state.massGrams, }; const docId = state.docKey; finishDrag(event.pointerId); @@ -775,7 +1051,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) { @@ -787,14 +1063,18 @@ const useDocumentDrag = (options = {}) => { } commitActiveDragTransforms([state.docKey]); - const inertiaState = { - restRotation: state.restRotation, - dynamicRotation: state.dynamicRotation, + const finalRotation = state.rotation ?? state.restRotation; + const inertiaState: EngineInertiaState = { + docId: state.docKey, + restRotation: finalRotation, + dynamicRotation: 0, angularVelocity: state.angularVelocity, - rotation: state.rotation, + rotation: finalRotation, width: state.width, height: state.height, dragScale: state.dragScale || 1, + lastTimestamp: state.lastTimestamp, + massGrams: state.massGrams, }; const docId = state.docKey; finishDrag(event.pointerId); diff --git a/frontend/src/desktop/workspaceEngine.js b/frontend/src/desktop/workspaceEngine.ts similarity index 72% rename from frontend/src/desktop/workspaceEngine.js rename to frontend/src/desktop/workspaceEngine.ts index 08e5a38..0bf2ad5 100644 --- a/frontend/src/desktop/workspaceEngine.js +++ b/frontend/src/desktop/workspaceEngine.ts @@ -1,5 +1,132 @@ -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; + massGrams?: 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; + +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; @@ -7,18 +134,28 @@ export const DESK_DEFAULT_CANVAS_WIDTH = 1024; export const DESK_DEFAULT_CANVAS_HEIGHT = 680; export const DESK_CARD_MIN = 240; export const DESK_CARD_MAX = 340; +export const CARD_PAGE_WEIGHT_GRAMS = 5; +export const CARD_BASE_WEIGHT_GRAMS = 30; const DEFAULT_Z_START = 10; export const MIN_TIMESTEP = 1 / 120; export const MAX_TIMESTEP = 1 / 20; -export const MAX_DYNAMIC_ROTATION = 4; -export const MAX_ANGULAR_VELOCITY = 180; -export const ANGULAR_DAMPING = 11; -export const TORQUE_TO_ACCELERATION = 0.006; -export const SETTLE_ANGULAR_VELOCITY = 1.2; +export const MAX_DYNAMIC_ROTATION = 7; +export const MAX_ANGULAR_VELOCITY = 210; +export const ANGULAR_DAMPING = 10; +export const TORQUE_TO_ACCELERATION = 0.007; +export const SETTLE_ANGULAR_VELOCITY = 0.45; + +const getMassScale = (massGrams?: number): number => { + if (!Number.isFinite(massGrams) || Number(massGrams) <= 0) { + return 1; + } + const normalized = Math.max(Number(massGrams), CARD_BASE_WEIGHT_GRAMS) / CARD_BASE_WEIGHT_GRAMS; + return Math.max(normalized, 1); +}; export const applyDomTransform = ( - node, + node: HTMLElement | null, { centerX, centerY, @@ -27,8 +164,8 @@ export const applyDomTransform = ( rotation = 0, scale = 1, zIndex, - } = {}, -) => { + }: TransformOptions = {}, +): void => { if (!node) { return; } @@ -44,7 +181,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 +193,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 +203,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 +226,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 +242,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 +252,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 +283,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 +299,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 +318,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 +347,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 +372,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 +404,7 @@ const polygonCentroid = (polygon) => { }; }; const generateInitialLayout = ( - entries, + entries: LayoutGenerationEntry[], { canvasWidth, canvasHeight, @@ -270,9 +413,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 +425,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 +441,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 +524,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,9 +627,9 @@ export class WorkspaceEngine { this.initialLoadDone = false; } - updateConfig({ allowLayoutPersistence, tenantId, viewId }) { + updateConfig({ allowLayoutPersistence, tenantId, viewId }: WorkspaceEngineOptions): void { const allowChanged = - typeof allowLayoutPersistence === 'boolean' + allowLayoutPersistence !== undefined && allowLayoutPersistence !== this.allowLayoutPersistence; const tenantChanged = tenantId !== undefined && tenantId !== this.tenantId; const viewChanged = viewId !== undefined && viewId !== this.viewId; @@ -470,7 +673,7 @@ export class WorkspaceEngine { } } - setItems(items) { + setItems(items: DeskDocument[] | null): void { const normalized = Array.isArray(items) ? items : []; this.items = normalized; const canGenerateLayoutImmediately = @@ -484,28 +687,24 @@ export class WorkspaceEngine { this.recalcVisibleDocIds(); } - setDocumentLookup(map) { + setDocumentLookup(map: Map): void { this.documentLookup = map instanceof Map ? map : new Map(); this.recalcVisibleDocIds(); } - setEnsureDocumentSize(fn) { - if (typeof fn === 'function') { - this.ensureDocumentSize = fn; - } + setEnsureDocumentSize(fn: EnsureDocumentSize): void { + this.ensureDocumentSize = fn; } - setResolveBaseMetrics(fn) { - if (typeof fn === 'function') { - this.resolveBaseMetrics = fn; - } + setResolveBaseMetrics(fn: ResolveBaseMetrics): void { + this.resolveBaseMetrics = fn; } - setItemRefs(ref) { + setItemRefs(ref: ItemRefs | null): 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 +716,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 +725,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 +734,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 +749,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 +758,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 +767,7 @@ export class WorkspaceEngine { this.emit(); } - setPendingRemovalTag(payload) { + setPendingRemovalTag(payload: unknown): void { if (payload === this.pendingRemovalTag) { return; } @@ -576,11 +775,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 +787,16 @@ export class WorkspaceEngine { return this.layout.get(key) || null; } - updateLayoutEntry(docId, updater) { + updateLayoutEntry( + docId: string | number | null, + updater: (previous: LayoutEntry | null) => LayoutEntry | null, + ): 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 = updater(previous); if (!next) { this.layout.delete(key); } else { @@ -605,7 +807,7 @@ export class WorkspaceEngine { this.persistLayoutSnapshot(); } - bringToFront(docId) { + bringToFront(docId: string | number | null): void { if (docId == null) { return; } @@ -622,7 +824,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 +850,7 @@ export class WorkspaceEngine { }); } - finalizeGroupDrag(dragState) { + finalizeGroupDrag(dragState: DragState): void { if (!dragState?.groupItems) { return; } @@ -652,16 +863,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,34 +896,33 @@ export class WorkspaceEngine { this.persistLayoutSnapshot(); } - cancelInertiaAnimation(docId) { + cancelInertiaAnimation(docId: string | number | null): void { const key = docId != null ? String(docId) : null; if (!key) { return; } - if (typeof window === 'undefined') { - this.inertiaAnimations.delete(key); - return; - } const existing = this.inertiaAnimations.get(key); - if (existing && typeof window.cancelAnimationFrame === 'function') { + if (existing?.frameId != null) { window.cancelAnimationFrame(existing.frameId); } this.inertiaAnimations.delete(key); } - disposeInertiaAnimations() { - if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') { - this.inertiaAnimations.forEach((animation) => { - if (animation?.frameId != null) { - window.cancelAnimationFrame(animation.frameId); - } - }); - } + disposeInertiaAnimations(): void { + this.inertiaAnimations.forEach((animation) => { + if (animation?.frameId != null) { + window.cancelAnimationFrame(animation.frameId); + } + }); 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; @@ -726,26 +938,43 @@ export class WorkspaceEngine { return true; } + const massScale = getMassScale(simulationState.massGrams); const torqueAcceleration = torque * TORQUE_TO_ACCELERATION; let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt; - angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY); + const maxAngularVelocity = MAX_ANGULAR_VELOCITY / massScale; + angularVelocity = clamp(angularVelocity, -maxAngularVelocity, maxAngularVelocity); - const dampingConstant = typeof dampingOverride === 'number' ? dampingOverride : ANGULAR_DAMPING; + const dampingConstant = Number.isFinite(dampingOverride) + ? Number(dampingOverride) + : ANGULAR_DAMPING; const dampingFactor = Math.exp(-dampingConstant * dt); angularVelocity *= dampingFactor; let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt; - if (dynamicRotation > MAX_DYNAMIC_ROTATION) { - dynamicRotation = MAX_DYNAMIC_ROTATION; + const dynamicLimit = MAX_DYNAMIC_ROTATION / Math.sqrt(massScale); + if (dynamicRotation > dynamicLimit) { + dynamicRotation = dynamicLimit; angularVelocity = Math.min(angularVelocity, 0); - } else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) { - dynamicRotation = -MAX_DYNAMIC_ROTATION; + } else if (dynamicRotation < -dynamicLimit) { + dynamicRotation = -dynamicLimit; angularVelocity = Math.max(angularVelocity, 0); } - simulationState.angularVelocity = angularVelocity; - simulationState.dynamicRotation = dynamicRotation; - simulationState.rotation = simulationState.restRotation + dynamicRotation; + const isSettled = Math.abs(angularVelocity) < (SETTLE_ANGULAR_VELOCITY * 0.6) + || Math.abs(dynamicRotation) < (MAX_DYNAMIC_ROTATION * 0.05); + + if (isSettled) { + simulationState.angularVelocity = 0; + simulationState.dynamicRotation = 0; + simulationState.rotation = simulationState.restRotation; + } else { + simulationState.angularVelocity = angularVelocity; + simulationState.dynamicRotation = dynamicRotation; + simulationState.rotation = simulationState.restRotation + dynamicRotation; + if (Math.sign(simulationState.angularVelocity) !== Math.sign(angularVelocity)) { + simulationState.angularVelocity = angularVelocity; + } + } const rotation = simulationState.rotation; const nextEntry = { ...entry, rotation }; @@ -763,12 +992,12 @@ export class WorkspaceEngine { nextEntry.z, ); - const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY; return isSettled; } - startInertiaAnimation(docId, baseState) { - if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + startInertiaAnimation(docId: string | number | null, baseState: InertiaSimulationState): void { + const raf = window.requestAnimationFrame; + if (!raf) { return; } const key = docId != null ? String(docId) : null; @@ -778,19 +1007,19 @@ export class WorkspaceEngine { this.cancelInertiaAnimation(key); - const now = - typeof performance !== 'undefined' && typeof performance.now === 'function' - ? performance.now() - : Date.now(); + const now = performance?.now ? performance.now() : Date.now(); const simulationState = { ...baseState, docId: key, dragScale: baseState.dragScale || 1, lastTimestamp: now, + massGrams: Number.isFinite(baseState.massGrams) + ? Math.max(Number(baseState.massGrams), CARD_BASE_WEIGHT_GRAMS) + : CARD_BASE_WEIGHT_GRAMS, }; - const step = (timestamp) => { + const step = (timestamp: number) => { const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16; const previous = simulationState.lastTimestamp; let dt = (safeTimestamp - previous) / 1000; @@ -807,14 +1036,14 @@ export class WorkspaceEngine { this.persistLayoutSnapshot(); return; } - simulationState.frameId = window.requestAnimationFrame(step); + simulationState.frameId = raf(step); }; - simulationState.frameId = window.requestAnimationFrame(step); + simulationState.frameId = raf(step); this.inertiaAnimations.set(key, simulationState); } - syncLayoutSnapshot() { + syncLayoutSnapshot(): void { if (this.dragInProgress) { this.pendingSnapshotSync = true; return; @@ -824,7 +1053,7 @@ export class WorkspaceEngine { this.emit(); } - async persistLayoutSnapshot() { + async persistLayoutSnapshot(): Promise { if (this.dragInProgress) { this.pendingPersistSync = true; return; @@ -885,17 +1114,13 @@ export class WorkspaceEngine { } }; - if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') { - this.persistDebounceId = window.setTimeout(() => { - this.persistDebounceId = null; - void persistTask(); - }, 100); - } else { - await persistTask(); - } + this.persistDebounceId = window.setTimeout(() => { + this.persistDebounceId = null; + void persistTask(); + }, 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)); @@ -919,11 +1144,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); @@ -951,8 +1176,12 @@ export class WorkspaceEngine { if (existing) { const defaultCenterX = (minCenterX + maxCenterX) / 2; const defaultCenterY = (minCenterY + maxCenterY) / 2; - const prevCenterX = typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX; - const prevCenterY = typeof existing.centerY === 'number' ? existing.centerY : defaultCenterY; + const prevCenterX = Number.isFinite(existing.centerX) + ? Number(existing.centerX) + : defaultCenterX; + const prevCenterY = Number.isFinite(existing.centerY) + ? Number(existing.centerY) + : defaultCenterY; const centerX = clamp(prevCenterX, minCenterX, maxCenterX); const centerY = clamp(prevCenterY, minCenterY, maxCenterY); const rotation = existing.rotation ?? 0; @@ -996,11 +1225,8 @@ export class WorkspaceEngine { this.recalcVisibleDocIds(); } - recalcVisibleDocIds() { + recalcVisibleDocIds(): void { const ensureSize = this.ensureDocumentSize; - if (typeof ensureSize !== 'function') { - return; - } const layoutMap = this.layout; const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH; @@ -1021,7 +1247,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; @@ -1074,8 +1300,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) { @@ -1129,16 +1355,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, @@ -1151,7 +1377,7 @@ export class WorkspaceEngine { }; } - emit() { + emit(): void { this.snapshotCache = this.buildSnapshot(); this.subscribers.forEach((listener) => { try { @@ -1162,7 +1388,7 @@ export class WorkspaceEngine { }); } - async loadPersistedLayout() { + async loadPersistedLayout(): Promise { if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) { return; } @@ -1210,9 +1436,12 @@ export class WorkspaceEngine { } } -export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => { +export const useWorkspaceSnapshot = ( + engine: WorkspaceEngine, + useSyncExternalStoreHook: UseSyncExternalStoreHook, +): WorkspaceSnapshot => { const useSyncExternalStore = useSyncExternalStoreHook; - if (typeof useSyncExternalStore !== 'function') { + if (!useSyncExternalStore) { throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook'); } return useSyncExternalStore( @@ -1223,8 +1452,11 @@ export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => { }; /* istanbul ignore next */ -if (typeof module !== 'undefined' && module && module.exports) { - module.exports = { +const commonJsModule = (globalThis as typeof globalThis & { + module?: { exports?: Record }; +}).module; +if (commonJsModule?.exports) { + commonJsModule.exports = { WorkspaceEngine, DESK_CANVAS_PADDING, DESK_ROTATION_RANGE, diff --git a/frontend/src/detail/PreviewZoomOverlay.jsx b/frontend/src/detail/PreviewZoomOverlay.tsx similarity index 50% rename from frontend/src/detail/PreviewZoomOverlay.jsx rename to frontend/src/detail/PreviewZoomOverlay.tsx index 2426f2c..e7db638 100644 --- a/frontend/src/detail/PreviewZoomOverlay.jsx +++ b/frontend/src/detail/PreviewZoomOverlay.tsx @@ -1,40 +1,82 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { CSSProperties, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; import { clamp } from '../utils/math'; +import PdfViewer from '../preview/PdfViewer'; + +type DocumentLike = { + id?: string | number; + title?: string; + content_type?: string | null; + [key: string]: unknown; +}; + +interface PreviewZoomOverlayProps { + open?: boolean; + onClose?: () => void; + document?: DocumentLike | null; +} + +type NaturalSize = { width: number | null; height: number | null }; +type FocusPoint = { xRatio: number; yRatio: number } | null; +type DisplayKind = 'image' | 'pdf'; + +type DocumentLink = { + url: string; + alt?: string; + contentType?: string | null; +}; + +type DocumentLikeWithPreview = DocumentLike & { documentLink?: DocumentLink }; + +const determineDisplayKind = (entry?: DocumentLink | null): DisplayKind => { + const type = entry?.contentType?.toLowerCase?.() || ''; + if (type.includes('pdf')) { + return 'pdf'; + } + if (type.startsWith('image/')) { + return 'image'; + } + const url = entry?.url?.toLowerCase?.() || ''; + if (url.endsWith('.pdf')) { + return 'pdf'; + } + return 'image'; +}; const noop = () => {}; -const ensureDocumentRoot = () => { - if (typeof document === 'undefined') { - return null; - } - return document.body; -}; - -const PreviewZoomOverlay = ({ +const PreviewZoomOverlay: React.FC = ({ open = false, - display = null, onClose = noop, + document: overlayDocument = null, }) => { - const portalTarget = ensureDocumentRoot(); + const portalTarget = document.body; 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 [documentSnapshot, setDocumentSnapshot] = useState(null); + const scrollRef = useRef(null); + const mediaRef = useRef(null); + const focusRef = useRef(null); + const previouslyFocusedRef = useRef(null); + const visibilityTimerRef = useRef(null); + const displayTimerRef = useRef(null); + + const currentDocument = overlayDocument as DocumentLikeWithPreview | null; useEffect(() => { - if (display?.url) { - setDisplaySnapshot(display); + if (currentDocument?.documentLink?.url) { + setDocumentSnapshot(currentDocument); } - }, [display]); + }, [currentDocument]); + + const activeDocument = open && currentDocument?.documentLink?.url ? currentDocument : documentSnapshot; + const documentLink = activeDocument?.documentLink || null; + const displayKind = useMemo(() => determineDisplayKind(documentLink), [documentLink]); + const isPdfDisplay = displayKind === 'pdf'; + const documentTitle = activeDocument?.title || undefined; + const effectiveAlt = documentLink?.alt || documentTitle || 'Document preview'; useEffect(() => { if (visibilityTimerRef.current) { @@ -46,7 +88,7 @@ const PreviewZoomOverlay = ({ displayTimerRef.current = null; } - if (open && display?.url) { + if (open && documentLink?.url) { setRenderBackdrop(true); displayTimerRef.current = requestAnimationFrame(() => { displayTimerRef.current = requestAnimationFrame(() => { @@ -62,7 +104,7 @@ const PreviewZoomOverlay = ({ } setBackdropVisible(false); - visibilityTimerRef.current = setTimeout(() => { + visibilityTimerRef.current = window.setTimeout(() => { setRenderBackdrop(false); }, 260); @@ -72,7 +114,7 @@ const PreviewZoomOverlay = ({ visibilityTimerRef.current = null; } }; - }, [open, display?.url]); + }, [open, documentLink?.url]); useEffect(() => () => { if (visibilityTimerRef.current) { @@ -83,6 +125,9 @@ const PreviewZoomOverlay = ({ } }, []); + useEffect(() => { + }, [isPdfDisplay, open, documentLink?.url]); + useEffect(() => { setIsNativeScale(false); setNaturalSize({ width: null, height: null }); @@ -92,7 +137,7 @@ const PreviewZoomOverlay = ({ scrollEl.scrollLeft = 0; scrollEl.scrollTop = 0; } - }, [open]); + }, [open, documentLink?.url, displayKind]); useEffect(() => { @@ -101,13 +146,13 @@ const PreviewZoomOverlay = ({ } const scrollEl = scrollRef.current; - const imageEl = imageRef.current; - if (!scrollEl || !imageEl) { + const mediaEl = mediaRef.current; + if (!scrollEl || !mediaEl) { return; } - const imageWidth = imageEl.naturalWidth || imageEl.clientWidth; - const imageHeight = imageEl.naturalHeight || imageEl.clientHeight; + const imageWidth = naturalSize.width || mediaEl.clientWidth; + const imageHeight = naturalSize.height || mediaEl.clientHeight; if (!(imageWidth > 0 && imageHeight > 0)) { return; } @@ -125,27 +170,17 @@ const PreviewZoomOverlay = ({ useEffect(() => { if (!open) { - if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') { - previouslyFocusedRef.current.focus(); - } + previouslyFocusedRef.current?.focus?.(); previouslyFocusedRef.current = null; return; } - if (typeof document !== 'undefined') { - const active = document.activeElement; - if (active && typeof active.focus === 'function') { - previouslyFocusedRef.current = active; - } else { - previouslyFocusedRef.current = null; - } - } + const active = document.activeElement; + previouslyFocusedRef.current = active instanceof HTMLElement ? active : null; }, [open]); - const activeDisplay = open && display?.url ? display : displaySnapshot; - useEffect(() => { - if (!activeDisplay?.url) { + if (!documentLink?.url) { return; } @@ -155,24 +190,21 @@ const PreviewZoomOverlay = ({ scrollEl.scrollTop = 0; } focusRef.current = null; - }, [activeDisplay?.url]); + }, [documentLink?.url]); useEffect(() => { - if (!renderBackdrop || !activeDisplay?.url) { + if (!renderBackdrop || !documentLink?.url) { return undefined; } const frame = requestAnimationFrame(() => { - const scrollEl = scrollRef.current; - if (scrollEl && typeof scrollEl.focus === 'function') { - scrollEl.focus({ preventScroll: true }); - } + scrollRef.current?.focus?.({ preventScroll: true }); }); return () => cancelAnimationFrame(frame); - }, [renderBackdrop, activeDisplay?.url]); + }, [renderBackdrop, documentLink?.url]); - const handleKeyDown = (event) => { + const handleKeyDown = (event: React.KeyboardEvent) => { event.stopPropagation(); if (!open) { @@ -205,27 +237,16 @@ const PreviewZoomOverlay = ({ return; } - if (key === 'ArrowLeft') { - if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) { - event.preventDefault(); - activeDisplay.goPrev(); - } - return; - } - - if (key === 'ArrowRight') { - if (activeDisplay?.canGoNext && activeDisplay?.goNext) { - event.preventDefault(); - activeDisplay.goNext(); - } - } }; - const toggleZoomAtPoint = (clientX, clientY) => { - const img = imageRef.current; + const toggleZoomAtPoint = (clientX: number, clientY: number) => { + if (isPdfDisplay) { + return; + } + const media = mediaRef.current; setIsNativeScale((current) => { - if (!current && img) { - const rect = img.getBoundingClientRect(); + if (!current && media) { + const rect = media.getBoundingClientRect(); const xRatio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5; const yRatio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5; focusRef.current = { @@ -239,26 +260,23 @@ const PreviewZoomOverlay = ({ }); }; - const handleImageClick = (event) => { - event.stopPropagation(); + const handleContentClick = (event: React.MouseEvent) => { + if (isPdfDisplay) { + return; + } toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0); }; - if (!renderBackdrop || !activeDisplay?.url || !portalTarget) { - return null; - } + const shouldRender = renderBackdrop && Boolean(documentLink?.url); - const effectiveDisplay = activeDisplay; - const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext); - const stageClassName = [ - 'preview-zoom__stage', - ] - .filter(Boolean) - .join(' '); + const stageClassName = isPdfDisplay + ? 'preview-zoom__stage preview-zoom__stage--pdf' + : 'preview-zoom__stage'; const containerClassName = [ 'preview-zoom__scroll', - isNativeScale ? 'preview-zoom__scroll--native' : '', + isNativeScale ? 'preview-zoom__scroll--native' : null, + isPdfDisplay ? 'preview-zoom__scroll--pdf' : null, ] .filter(Boolean) .join(' '); @@ -270,7 +288,7 @@ const PreviewZoomOverlay = ({ .filter(Boolean) .join(' '); - const imageStyle = isNativeScale + const contentStyle: CSSProperties = isNativeScale ? { cursor: 'zoom-out', width: naturalSize.width ? `${naturalSize.width}px` : 'auto', @@ -286,6 +304,12 @@ const PreviewZoomOverlay = ({ touchAction: 'manipulation', }; + if (!shouldRender) { + return null; + } + + const effectiveDisplay = documentLink; + return createPortal( (
-
event.stopPropagation()} + className={stageClassName} + onClick={(event) => { + if (event.target === event.currentTarget) { + onClose(); + } + }} > - {effectiveDisplay.alt { - setNaturalSize({ - width: event.currentTarget.naturalWidth || null, - height: event.currentTarget.naturalHeight || null, - }); - }} - onClick={handleImageClick} - style={imageStyle} - /> -
- {navVisible ? ( -
- - -
- ) : null} + onClick={handleContentClick} + style={contentStyle} + /> + )} +
), diff --git a/frontend/src/detail/useDetailWorkspace.js b/frontend/src/detail/useDetailWorkspace.ts similarity index 67% rename from frontend/src/detail/useDetailWorkspace.js rename to frontend/src/detail/useDetailWorkspace.ts index 2c0478e..115684c 100644 --- a/frontend/src/detail/useDetailWorkspace.js +++ b/frontend/src/detail/useDetailWorkspace.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo } from 'react'; +import type { MutableRefObject } from 'react'; import { resolveDocumentAssetUrl } from '../asset_manager'; import { useDetailPanel } from '../app/useDetailPanel'; import { @@ -6,11 +7,73 @@ import { getRowId, isDocumentRowKey, } from '../app/appLayoutUtils'; +import type { DocumentInfoPanelProps } from '../documents/DocumentInfoPanel'; +import type { EnsureAssetUrl, GetDocumentAsset } from '../utils/ocr'; + +type Identifier = string | number; + +interface DocumentLike { + id?: Identifier; + folder_id?: Identifier | 'root'; + title?: string; + [key: string]: unknown; +} + +interface FolderNode { + id: Identifier | 'root'; + name?: string; + parentId?: Identifier | 'root'; +} + +type DocumentLink = { + url?: string; + contentType?: string | null; +} | null; + +interface UseDetailWorkspaceArgs { + documents: DocumentLike[]; + selectionOrder: string[]; + selectedDocumentIds: Identifier[]; + documentLookup: Map; + folderNodes: Map; + ensureFolderData: (folderId: Identifier | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise; + detailPanelControlRef: MutableRefObject<{ open?: (args?: { documentIds?: Identifier[] }) => void; close?: () => void } | null>; + detailFolderFetchRef: MutableRefObject>; + documentLinks: Map; + previewDocumentId?: Identifier | null; + activePreviewId?: Identifier | null; + openDocumentPreview?: (args: { documentIds: Identifier[] }) => void; + handleDocumentTitleUpdate?: (docId: Identifier, title: string) => Promise | boolean; + handleDocumentIssuedUpdate?: (docId: Identifier, issued: number | null) => Promise | boolean; + handleDocumentTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void; + handleTagRemove?: (...args: unknown[]) => void; + ensureAssetUrl?: EnsureAssetUrl; + getDocumentAsset?: GetDocumentAsset; + correspondents?: unknown[]; + handleCorrespondentAdd?: (...args: unknown[]) => void; + handleCorrespondentRemove?: (...args: unknown[]) => void; + resolveApiPath?: (path: string) => string; + selectFolder?: (folderId?: Identifier | 'root') => void; + tags?: unknown[]; + tagLookupById?: Map | null; +} + +interface UseDetailWorkspaceResult { + detailPanelProps: DocumentInfoPanelProps; + detailPanelOpen: boolean; + openDetailPanel: ReturnType['openDetailPanel']; + closeDetailPanel: ReturnType['closeDetailPanel']; + handleDetailPanelClose: () => void; + inspectDocument: (docId: Identifier | null) => void; + previewActive: boolean; + previewWorkspaceDocument: DocumentLike | null; + documentLink: DocumentLink; + resolveThumbnailUrlForDoc: (doc: DocumentLike | null) => string | null; + resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>; +} const useDetailWorkspace = ({ documents, - searchResults, - previewDocuments, selectionOrder, selectedDocumentIds, documentLookup, @@ -18,7 +81,7 @@ const useDetailWorkspace = ({ ensureFolderData, detailPanelControlRef, detailFolderFetchRef, - previewEntries, + documentLinks, previewDocumentId, activePreviewId, openDocumentPreview, @@ -28,7 +91,6 @@ const useDetailWorkspace = ({ handleTagRemove, ensureAssetUrl, getDocumentAsset, - ensurePreviewData, correspondents, handleCorrespondentAdd, handleCorrespondentRemove, @@ -36,7 +98,7 @@ const useDetailWorkspace = ({ selectFolder, tags, tagLookupById, -}) => { +}: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => { const orderedSelectedDocuments = useMemo(() => { const ordered = []; const seen = new Set(); @@ -177,29 +239,19 @@ const useDetailWorkspace = ({ [folderNodes], ); - const detailPanelPreviewEntry = useMemo(() => { - if (!detailPanelDocument) { - return null; - } - return previewEntries.get(detailPanelDocument.id) || null; - }, [detailPanelDocument, previewEntries]); - - const previewWorkspaceEntry = useMemo(() => { - if (!previewDocumentId) { - return null; - } - return previewEntries.get(previewDocumentId) || null; - }, [previewDocumentId, previewEntries]); + const documentLink = useMemo( + () => (detailPanelDocument ? documentLinks.get(detailPanelDocument.id) || null : null), + [detailPanelDocument, documentLinks], + ); const previewWorkspaceDocument = useMemo(() => { if (!previewDocumentId) { return null; } - const pool = searchResults ?? documents; - return pool.find((doc) => doc.id === previewDocumentId) - || previewDocuments?.get?.(previewDocumentId) + return documentLookup.get(previewDocumentId) + || documents.find((doc) => doc.id === previewDocumentId) || null; - }, [previewDocumentId, searchResults, documents, previewDocuments]); + }, [previewDocumentId, documentLookup, documents]); const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument); @@ -233,14 +285,13 @@ const useDetailWorkspace = ({ tagLookupById, onTagAdd: handleDocumentTagAdd, onTagRemove: handleTagRemove, - previewEntry: detailPanelPreviewEntry, + documentLink, onOpenPreview: openDocumentPreview, activePreviewId, onUpdateTitle: handleDocumentTitleUpdate, onUpdateIssued: handleDocumentIssuedUpdate, ensureAssetUrl, getDocumentAsset, - hydrateDocument: ensurePreviewData, correspondents, onCorrespondentAdd: handleCorrespondentAdd, onCorrespondentRemove: handleCorrespondentRemove, @@ -254,7 +305,6 @@ const useDetailWorkspace = ({ correspondents, detailPanelDocument, ensureAssetUrl, - ensurePreviewData, getDocumentAsset, handleCorrespondentAdd, handleCorrespondentRemove, @@ -267,7 +317,7 @@ const useDetailWorkspace = ({ resolveApiPath, resolveFolderPath, selectFolder, - detailPanelPreviewEntry, + documentLink, tags, tagLookupById, ], @@ -282,7 +332,7 @@ const useDetailWorkspace = ({ inspectDocument, previewActive, previewWorkspaceDocument, - previewWorkspaceEntry, + documentLink, resolveThumbnailUrlForDoc, resolveFolderPath, }; diff --git a/frontend/src/documents/CorrespondentLinks.jsx b/frontend/src/documents/CorrespondentLinks.tsx similarity index 66% rename from frontend/src/documents/CorrespondentLinks.jsx rename to frontend/src/documents/CorrespondentLinks.tsx index f37c268..fdba8e4 100644 --- a/frontend/src/documents/CorrespondentLinks.jsx +++ b/frontend/src/documents/CorrespondentLinks.tsx @@ -2,7 +2,19 @@ import React from 'react'; const NBSP = String.fromCharCode(160); -const CorrespondentLinks = ({ +export interface CorrespondentLinkEntry { + id?: string | number | null; + name?: string | null; + key?: string; +} + +interface CorrespondentLinksProps { + correspondents?: CorrespondentLinkEntry[]; + activeCorrespondentIdSet?: Set; + onCorrespondentClick?: (id: string | number) => void; +} + +const CorrespondentLinks: React.FC = ({ correspondents, activeCorrespondentIdSet, onCorrespondentClick, @@ -11,8 +23,8 @@ const CorrespondentLinks = ({ return null; } - const activeSet = activeCorrespondentIdSet || new Set(); - const handleClick = (event, correspondent) => { + const activeSet = activeCorrespondentIdSet || new Set(); + const handleClick = (event: React.MouseEvent | React.KeyboardEvent, correspondent: CorrespondentLinkEntry) => { if (!onCorrespondentClick || correspondent.id == null) { return; } @@ -27,11 +39,12 @@ const CorrespondentLinks = ({ if (isActive) classNames.push('is-active'); if (!hasHandler) classNames.push('is-static'); const isLast = index === correspondents.length - 1; - const label = isLast ? `${correspondent.name}:${NBSP}` : correspondent.name; + const fallbackLabel = correspondent.name ?? '—'; + const label = isLast ? `${fallbackLabel}:${NBSP}` : fallbackLabel; return ( - - - ) : ( - <> -

{summary.title}

- {editableTitle ? ( - - ) : null} - - ) + const renderTitleEditForm = (extraClassName?: string) => ( +
+ { + setTitleDraft(event.target.value); + if (titleError) { + setTitleError(null); + } + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelTitleEdit(); + } + }} + aria-label="Document title" + autoFocus + disabled={titleSaving} + /> + + +
); + const titleMetaDisplay = editableTitle && isTitleEditing + ? renderTitleEditForm('doc-title-edit--inline') + : ( + <> + {document?.title} + {editableTitle ? ( + + ) : null} + + ); + const issuedDisplay = editableIssued && isIssuedEditing ? (
-
) : ( @@ -604,184 +695,92 @@ const DocumentSummarySection = ({ ); - const metaItems = [ - { - key: 'issued', - label: 'Issued', - valueContent: issuedDisplay, - error: issuedError, - }, - ...metaRows.map((row) => ({ + const tagsValueContent = ( + onTagRemove(document.id, tag.id) + : undefined + } + onAdd={ + onTagAdd + ? ({ value, option }) => onTagAdd(document, value, { option }) + : undefined + } + datalistOptions={tagOptions} + className="document-summary__tags" + /> + ); + + const correspondentsValueContent = ( + + onCorrespondentRemove({ + documentId: document.id, + correspondentId: entry.id, + }) + : undefined + } + onAdd={ + onCorrespondentAdd + ? ({ name, option }) => + onCorrespondentAdd({ + document, + name, + option, + }) + : undefined + } + showCount + datalistOptions={correspondentOptions} + className="document-summary__correspondents" + /> + ); + + const summaryRowOverrides = { + title: { valueContent: titleMetaDisplay, error: titleError }, + issued: { valueContent: issuedDisplay, error: issuedError }, + tags: { valueContent: tagsValueContent }, + correspondents: { valueContent: correspondentsValueContent }, + } as Record; + + const baseRows: MetaItem[] = [...summaryRows, ...extraSummaryRows].map((row) => { + const overrides = summaryRowOverrides[row.key] || {}; + return { key: row.key, label: row.label, - fallbackValue: row.value, - })), - ]; + valueContent: overrides.valueContent ?? null, + fallbackValue: overrides.valueContent ? row.value : row.value, + error: overrides.error ?? null, + }; + }); - const detailRows = Array.isArray(detailItems) - ? detailItems.map((item, index) => ({ - key: `detail-${item?.label || index}`, - label: item?.label || '—', - fallbackValue: item?.value, - })) - : []; - - const compactRows = [...metaItems, ...detailRows]; - - const renderTags = () => ( -
- onTagRemove(document.id, tag.id) - : undefined - } - onAdd={ - onTagAdd - ? ({ value, option }) => onTagAdd(document, value, { option }) - : undefined - } - datalistOptions={tagOptions} - className="document-summary__tags" - /> -
- ); - - const renderCorrespondents = () => ( -
- - onCorrespondentRemove({ - documentId: document.id, - correspondentId: entry.id, - }) - : undefined - } - onAdd={ - onCorrespondentAdd - ? ({ name, option }) => - onCorrespondentAdd({ - document, - name, - option, - }) - : undefined - } - showCount - datalistOptions={correspondentOptions} - className="document-summary__correspondents" - /> -
- ); - - if (isCompactLayout) { - return ( -
-
- -
- {titleError ?
{titleError}
: null} - {renderTags()} - {renderCorrespondents()} - {compactRows.length ? ( -
-
- {compactRows.map((item) => ( -
-
{item.label}
-
- {item.valueContent != null && item.valueContent !== '' - ? item.valueContent - : item.fallbackValue || '—'} -
- {item.error ?
{item.error}
: null} -
- ))} -
-
- ) : null} -
- ); - } + const allRows = baseRows; + const summaryClass = `document-summary${isCompactLayout ? ' document-summary--compact' : ''}`; + const sectionClass = `document-summary__section document-summary__meta${isCompactLayout ? ' document-summary__meta--compact' : ''}`; + const listClass = `document-summary__details-list${isCompactLayout ? ' document-summary__details-list--meta' : ''}`; return ( -
-
-
- {editableTitle && isTitleEditing ? ( -
- { - setTitleDraft(event.target.value); - if (titleError) { - setTitleError(null); - } - }} - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.preventDefault(); - cancelTitleEdit(); - } - }} - aria-label="Document title" - autoFocus - disabled={titleSaving} - /> - - -
- ) : ( - <> -

{summary.title}

- {editableTitle ? ( - - ) : null} - - )} -
-
- {titleError ?
{titleError}
: null} - -
-
- Issued: - {issuedDisplay} -
- {issuedError ?
{issuedError}
: null} - - {metaRows.map((row) => ( -
- {row.label}: - {row.value} -
- ))} -
- - {renderTags()} - - {renderCorrespondents()} +
+
+
+ {allRows.map((item) => ( +
+
{item.label}
+
+ {item.valueContent != null && item.valueContent !== '' + ? item.valueContent + : item.fallbackValue || '—'} +
+ {item.error ?
{item.error}
: null} +
+ ))} +
+
); }; diff --git a/frontend/src/documents/DocumentThumbnailImage.jsx b/frontend/src/documents/DocumentThumbnailImage.tsx similarity index 68% rename from frontend/src/documents/DocumentThumbnailImage.jsx rename to frontend/src/documents/DocumentThumbnailImage.tsx index aeb038a..1e0679b 100644 --- a/frontend/src/documents/DocumentThumbnailImage.jsx +++ b/frontend/src/documents/DocumentThumbnailImage.tsx @@ -1,11 +1,25 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { CSSProperties, JSX, MutableRefObject } from 'react'; +import { + getAssetFromVersion, + resolveDocumentAssetUrl, + createAssetView, +} from '../asset_manager'; +import type { + DocumentLike as AssetManagerDocumentLike, + AssetLike as AssetManagerAssetLike, + EnsureAssetUrl as AssetManagerEnsureAssetUrl, + GetAsset as AssetManagerGetAsset, +} 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, +) => { + const targetRef = useRef(null); const [isVisible, setIsVisible] = useState(false); useEffect(() => { @@ -24,12 +38,12 @@ const useLazyVisibility = (rootRef, resetKey) => { return undefined; } - if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') { + if (!window.IntersectionObserver) { setIsVisible(true); return undefined; } - const observer = new IntersectionObserver( + const observer = new window.IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { @@ -52,10 +66,24 @@ const useLazyVisibility = (rootRef, resetKey) => { return { ref: targetRef, isVisible }; }; -const getPageCount = (doc) => - Number.isFinite(doc?.current_version?.metadata?.page_count) - ? doc.current_version.metadata.page_count - : null; +const getPageCount = (doc?: DocumentLike | null) => { + const count = doc?.current_version?.metadata?.page_count; + return Number.isFinite(count) ? Number(count) : null; +}; + +type DocumentLike = AssetManagerDocumentLike; +type AssetLike = AssetManagerAssetLike; +type EnsureAssetUrl = AssetManagerEnsureAssetUrl; +type GetDocumentAsset = AssetManagerGetAsset; + +interface DocumentThumbnailImageProps { + document?: DocumentLike | null; + ensureAssetUrl?: EnsureAssetUrl; + getDocumentAsset?: GetDocumentAsset; + alt?: string; + maxSize?: number; + scrollRootRef?: MutableRefObject | null; +} const DocumentThumbnailImage = ({ document, @@ -64,12 +92,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 +117,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 +126,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 76% rename from frontend/src/documents/DocumentsGrid.jsx rename to frontend/src/documents/DocumentsGrid.tsx index 4d84c95..f9f0c99 100644 --- a/frontend/src/documents/DocumentsGrid.jsx +++ b/frontend/src/documents/DocumentsGrid.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useMemo } from 'react'; +import type { DragEvent, MouseEvent, RefObject } from 'react'; import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons'; import DocumentThumbnailImage from './DocumentThumbnailImage'; import CorrespondentLinks from './CorrespondentLinks'; @@ -6,11 +7,85 @@ import { getTagColorStyle } from '../utils/colors'; import { resolveCorrespondents } from './correspondents'; import { writeTagTransferData } from './tagTransfer'; import useInlineRename from './useInlineRename'; +import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; -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[]; + 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) => void; + scrollRef?: RefObject; + onCorrespondentClick?: (correspondentId: Identifier) => void; + activeCorrespondentIdSet?: Set | null; + onDocumentRename?: (docId: Identifier, title: string) => Promise | boolean; +} + +const DocumentsGrid: React.FC = ({ entries, - selectedDocumentIdsSet, - selectedFolderIdsSet, draggingDocumentIdsSet, draggedFolderId, onFolderClick, @@ -35,10 +110,16 @@ const DocumentsGrid = ({ scrollRef, onCorrespondentClick, activeCorrespondentIdSet, - onClearSelection, onDocumentRename, onFolderRename, }) => { + const { + selectedDocumentIds, + selectedFolderIds, + clearSelection, + } = useWorkspaceSelectionContext(); + const selectedDocumentIdsSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]); + const selectedFolderIdsSet = useMemo(() => new Set(selectedFolderIds || []), [selectedFolderIds]); const { editingId: editingDocumentId, draftValue: documentDraft, @@ -48,9 +129,9 @@ const DocumentsGrid = ({ submitEditing: submitDocumentEditing, savingId: savingDocumentId, attachInputRef: attachDocumentInputRef, - } = useInlineRename(onDocumentRename, { - getCurrentValue: (doc) => doc?.title ?? '', - getEntityId: (doc) => doc?.id ?? null, + } = useInlineRename(onDocumentRename, { + getCurrentValue: (doc: DocumentLike) => doc?.title ?? '', + getEntityId: (doc: DocumentLike) => doc?.id ?? null, }); const { @@ -62,9 +143,9 @@ const DocumentsGrid = ({ submitEditing: submitFolderEditing, savingId: savingFolderId, attachInputRef: attachFolderInputRef, - } = useInlineRename(onFolderRename, { - getCurrentValue: (folder) => folder?.name ?? '', - getEntityId: (folder) => folder?.id ?? null, + } = useInlineRename(onFolderRename, { + getCurrentValue: (folder: FolderLike) => folder?.name ?? '', + getEntityId: (folder: FolderLike) => folder?.id ?? null, }); const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0; @@ -78,7 +159,7 @@ const DocumentsGrid = ({ style={{ '--documents-grid-icon-size': `${gridIconSize}px` }} onClick={(event) => { if (event.target === event.currentTarget) { - onClearSelection?.(); + clearSelection(); } }} > @@ -94,7 +175,7 @@ const DocumentsGrid = ({ const classes = ['document-card', 'folder-card']; if (isDraggingFolder) classes.push('is-dragging'); if (isSelectedFolder) classes.push('selected'); - const canRenameFolder = folder.id !== 'root' && typeof onFolderRename === 'function'; + const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root'; const isFolderEditing = editingFolderId === folder.id; const folderDraftValue = isFolderEditing ? folderDraft : folder.name; const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : ''; @@ -353,20 +434,26 @@ const DocumentsGrid = ({
{visibleTags.length > 0 && (
- {visibleTags.map((tag) => { + {visibleTags.map((tag, index) => { const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color; const style = getTagColorStyle(colorSource); + const tagId = tag?.id ?? null; + const clickable = tagId != null && typeof onTagClick === 'function'; + const key = tagId ?? `${doc.id}-tag-${index}`; return ( { + role={clickable ? 'button' : undefined} + onClick={clickable ? (event) => { event.stopPropagation(); - onTagClick?.(tag.id); - }} + if (tagId == null) { + return; + } + onTagClick?.(tagId); + } : undefined} draggable onDragStart={(event) => { event.stopPropagation(); @@ -382,13 +469,16 @@ const DocumentsGrid = ({ onDragEnd={(event) => { event.stopPropagation(); }} - onKeyDown={(event) => { + onKeyDown={clickable ? (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); - onTagClick?.(tag.id); + if (tagId == null) { + return; + } + onTagClick?.(tagId); } - }} + } : undefined} > {tag.label} diff --git a/frontend/src/documents/DocumentsList.jsx b/frontend/src/documents/DocumentsList.tsx similarity index 77% rename from frontend/src/documents/DocumentsList.jsx rename to frontend/src/documents/DocumentsList.tsx index 714b2d8..62cc7ec 100644 --- a/frontend/src/documents/DocumentsList.jsx +++ b/frontend/src/documents/DocumentsList.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useMemo } 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'; @@ -7,12 +8,90 @@ import CorrespondentLinks from './CorrespondentLinks'; import { resolveCorrespondents } from './correspondents'; import { writeTagTransferData } from './tagTransfer'; import useInlineRename from './useInlineRename'; +import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; -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; + 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) => void; + onCorrespondentClick?: (correspondentId: Identifier) => void; + activeCorrespondentIdSet?: Set | null; + scrollRef?: RefObject; +} + + +const DocumentsList: React.FC = ({ entries, focusedRowKey, - selectedDocumentIdsSet, - selectedFolderIdsSet, draggingDocumentIdsSet, draggedFolderId, ensureAssetUrl, @@ -38,8 +117,17 @@ const DocumentsList = ({ onCorrespondentClick, activeCorrespondentIdSet, scrollRef, - onClearSelection, }) => { + const { + selectedDocumentIds, + selectedFolderIds, + clearSelection, + } = useWorkspaceSelectionContext(); + const selectedDocumentIdsSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]); + const selectedFolderIdsSet = useMemo( + () => new Set(selectedFolderIds || []), + [selectedFolderIds], + ); const { editingId: editingDocumentId, draftValue: documentDraft, @@ -49,9 +137,9 @@ const DocumentsList = ({ submitEditing: submitDocumentEditing, savingId: savingDocumentId, attachInputRef: attachDocumentInputRef, - } = useInlineRename(onDocumentRename, { - getCurrentValue: (doc) => doc?.title ?? '', - getEntityId: (doc) => doc?.id ?? null, + } = useInlineRename(onDocumentRename, { + getCurrentValue: (doc: DocumentLike) => doc?.title ?? '', + getEntityId: (doc: DocumentLike) => doc?.id ?? null, }); const { @@ -63,9 +151,9 @@ const DocumentsList = ({ submitEditing: submitFolderEditing, savingId: savingFolderId, attachInputRef: attachFolderInputRef, - } = useInlineRename(onFolderRename, { - getCurrentValue: (folder) => folder?.name ?? '', - getEntityId: (folder) => folder?.id ?? null, + } = useInlineRename(onFolderRename, { + getCurrentValue: (folder: FolderLike) => folder?.name ?? '', + getEntityId: (folder: FolderLike) => folder?.id ?? null, }); @@ -77,11 +165,11 @@ const DocumentsList = ({ { - onClearSelection?.(); + clearSelection(); }} > - + @@ -98,7 +186,7 @@ const DocumentsList = ({ const isDraggingFolder = draggedFolderId === folder.id; const isSelectedFolder = selectedFolderIdsSet?.has(folder.id); const rowKey = `folder:${folder.id}`; - const canRenameFolder = folder.id !== 'root' && typeof onFolderRename === 'function'; + const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root'; const isFolderEditing = editingFolderId === folder.id; const folderDraftValue = isFolderEditing ? folderDraft : folder.name; const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : ''; @@ -368,20 +456,24 @@ const DocumentsList = ({ {(doc.tags || []).length > 0 && (
- {(doc.tags || []).map((tag) => { + {(doc.tags || []).map((tag, index) => { const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color; const style = getTagColorStyle(colorSource); + const tagId = tag?.id ?? null; + const clickable = tagId != null && typeof onTagClick === 'function'; + const key = tagId ?? `${doc.id}-tag-${index}`; return ( { + role={clickable ? 'button' : undefined} + onClick={clickable ? (event) => { event.stopPropagation(); - onTagClick?.(tag.id); - }} + if (tagId == null) return; + onTagClick?.(tagId); + } : undefined} draggable onDragStart={(event) => { event.stopPropagation(); @@ -397,13 +489,16 @@ const DocumentsList = ({ onDragEnd={(event) => { event.stopPropagation(); }} - onKeyDown={(event) => { + onKeyDown={clickable ? (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); - onTagClick?.(tag.id); + if (tagId == null) { + return; + } + onTagClick?.(tagId); } - }} + } : undefined} > {tag.label} diff --git a/frontend/src/documents/DocumentsManager.ts b/frontend/src/documents/DocumentsManager.ts new file mode 100644 index 0000000..dba5090 --- /dev/null +++ b/frontend/src/documents/DocumentsManager.ts @@ -0,0 +1,171 @@ +import { shallowEqual } from 'react-redux'; + +type DocumentId = string | number; + +export type ManagedDocument = { id?: DocumentId | null } & Record; + +type FetchDocument = (id: DocumentId) => Promise; + +class DocumentsManager { + private byId: Map; + + private fetcher?: FetchDocument; + + private inflight: Map>; + + private listeners: Set<() => void>; + + constructor( + fetchDocument?: FetchDocument, + ) { + this.byId = new Map(); + this.fetcher = fetchDocument; + this.inflight = new Map(); + this.listeners = new Set(); + } + + private emit() { + this.listeners.forEach((fn) => fn()); + } + + subscribe(listener: () => void) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + setFetcher(fetchDocument?: FetchDocument) { + this.fetcher = fetchDocument; + } + + ingest(rawDocs: unknown[] = []): { canonical: T[]; changed: boolean } { + const docs = rawDocs.map((doc) => doc as T).filter(Boolean); + let changed = false; + let nextById = this.byId; + const canonical: T[] = []; + + docs.forEach((doc) => { + const id = doc?.id; + if (id == null) { + canonical.push(doc); + return; + } + + const existing = nextById.get(id as DocumentId); + const merged = existing ? ({ ...existing, ...doc } as T) : ({ ...(doc as T) } as T); + const useExisting = existing && shallowEqual(existing, merged); + const nextDoc = useExisting ? (existing as T) : merged; + + if (!useExisting) { + if (!changed) { + nextById = new Map(this.byId); + } + nextById.set(id as DocumentId, nextDoc); + changed = true; + } + canonical.push(nextDoc); + }); + + if (changed) { + this.byId = nextById; + this.emit(); + } + + return { canonical, changed }; + } + + async ensure(id: DocumentId, fetcherOverride?: FetchDocument): Promise { + if (id == null) { + return null; + } + + const cached = this.byId.get(id); + if (cached) { + return cached; + } + + const fetcher = fetcherOverride || this.fetcher; + if (!fetcher) { + return null; + } + + const inflight = this.inflight.get(id); + if (inflight) { + return inflight; + } + + const request = (async () => { + try { + const fetched = await fetcher(id); + const { canonical } = this.ingest([fetched as unknown]); + return canonical[0] ?? null; + } finally { + this.inflight.delete(id); + } + })(); + + this.inflight.set(id, request); + return request; + } + + map(mapper: (doc: T) => T | undefined): boolean { + if (!this.byId.size) { + return false; + } + + let changed = false; + const next = new Map(); + this.byId.forEach((doc, key) => { + const updated = mapper(doc); + const nextDoc = updated === undefined ? doc : updated; + if (nextDoc !== doc) { + changed = true; + } + next.set(key, nextDoc ?? doc); + }); + + if (changed) { + this.byId = next; + this.emit(); + } + + return changed; + } + + remove(ids: Array): boolean { + if (!Array.isArray(ids) || ids.length === 0) { + return false; + } + let changed = false; + let next = this.byId; + ids.forEach((id) => { + if (next.has(id)) { + if (!changed) { + next = new Map(this.byId); + } + next.delete(id); + changed = true; + } + }); + if (changed) { + this.byId = next; + this.emit(); + } + return changed; + } + + getById(id: DocumentId): T | null { + return this.byId.get(id) ?? null; + } + + getMany(ids: Array = []): T[] { + return ids + .map((id) => this.byId.get(id) || null) + .filter((doc): doc is T => Boolean(doc)); + } + + getSnapshot(): Map { + return this.byId; + } +} + +export default DocumentsManager; diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.ts similarity index 60% rename from frontend/src/documents/DocumentsPanel.jsx rename to frontend/src/documents/DocumentsPanel.ts index d076f21..3f7c199 100644 --- a/frontend/src/documents/DocumentsPanel.jsx +++ b/frontend/src/documents/DocumentsPanel.ts @@ -1,3 +1,2 @@ export { default } from './panel/DocumentsPanel'; -export { default as createDocumentsSurface } from './panel/createDocumentsSurface'; export { createDocumentsTableHeaderActions } from './panel/DocumentsToolbar'; diff --git a/frontend/src/documents/SelectionAssignmentMenu.jsx b/frontend/src/documents/SelectionAssignmentMenu.tsx similarity index 58% rename from frontend/src/documents/SelectionAssignmentMenu.jsx rename to frontend/src/documents/SelectionAssignmentMenu.tsx index ca75ed8..3046c5f 100644 --- a/frontend/src/documents/SelectionAssignmentMenu.jsx +++ b/frontend/src/documents/SelectionAssignmentMenu.tsx @@ -1,31 +1,91 @@ -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; + 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'; + closeOnSelection?: boolean; + sortByState?: boolean; + freezeSortOnOpen?: boolean; +} + +const STATE_ORDER: Record = { all: 0, partial: 1, none: 2, }; -const normalizeItems = (items) => +const normalizeItems = (items?: SelectionAssignmentMenuItem[]): NormalizedSelectionAssignmentItem[] => (Array.isArray(items) ? items : []) - .filter((item) => item && typeof item.label === 'string' && item.label.trim().length > 0) - .map((item) => ({ - id: item.id ?? item.label, - label: item.label.trim(), - state: item.state === 'all' ? 'all' : item.state === 'partial' ? 'partial' : 'none', - count: typeof item.count === 'number' ? item.count : null, - total: typeof item.total === 'number' ? item.total : null, - payload: item.payload ?? item, - })); + .map((item) => { + if (!item) { + return null; + } + const trimmedLabel = item.label?.trim?.() || ''; + if (!trimmedLabel) { + return null; + } + const state: AssignmentState = item.state === 'all' + ? 'all' + : item.state === 'partial' + ? 'partial' + : 'none'; + const numericCount = Number.isFinite(item.count) ? Number(item.count) : null; + const numericTotal = Number.isFinite(item.total) ? Number(item.total) : null; + return { + id: item.id ?? trimmedLabel, + label: trimmedLabel, + state, + count: numericCount, + total: numericTotal, + payload: item.payload ?? item, + }; + }) + .filter((item): item is NormalizedSelectionAssignmentItem => Boolean(item)); -const SelectionAssignmentMenu = ({ +const SelectionAssignmentMenu: React.FC = ({ label, items = [], placeholder = 'Search…', emptyMessage = 'No entries', - createLabel = null, + createLabel = 'Add', onToggle, onCreate, disabled = false, @@ -34,14 +94,18 @@ const SelectionAssignmentMenu = ({ triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger', showStateIndicators = true, showCounts = true, - onOpenMenu = null, - renderItemLabel = null, + onOpenMenu, + renderItemLabel, positionStrategy = 'absolute', + closeOnSelection = true, + sortByState = true, + freezeSortOnOpen = false, }) => { - 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); + const [sortSnapshot, setSortSnapshot] = useState | null>(null); const { isOpen, @@ -55,7 +119,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) { @@ -81,43 +152,80 @@ const SelectionAssignmentMenu = ({ const normalizedItems = useMemo(() => normalizeItems(items), [items]); - const filteredItems = useMemo(() => { - const search = query.trim().toLowerCase(); - const sorted = normalizedItems.slice().sort((a, b) => { + const sortedByStateItems = useMemo(() => { + if (!sortByState) { + return normalizedItems; + } + return normalizedItems.slice().sort((a, b) => { const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state]; if (stateDiff !== 0) { return stateDiff; } return a.label.localeCompare(b.label); }); - if (!search) { - return sorted; + }, [normalizedItems, sortByState]); + + useEffect(() => { + if (!isOpen || !freezeSortOnOpen || !sortByState) { + setSortSnapshot(null); + return; } - return sorted.filter((item) => item.label.toLowerCase().includes(search)); - }, [normalizedItems, query]); + setSortSnapshot((prev) => prev ?? sortedByStateItems.map((item) => item.id)); + }, [isOpen, freezeSortOnOpen, sortByState, sortedByStateItems]); + + const orderedItems = useMemo(() => { + if (freezeSortOnOpen && sortSnapshot && sortByState) { + const itemMap = new Map( + sortedByStateItems.map((item) => [item.id, item]), + ); + const seen = new Set(); + const fromSnapshot = sortSnapshot + .map((id) => { + const entry = itemMap.get(id); + if (entry) { + seen.add(entry.id); + } + return entry || null; + }) + .filter((entry): entry is NormalizedSelectionAssignmentItem => Boolean(entry)); + const remaining = sortedByStateItems.filter((item) => !seen.has(item.id)); + return [...fromSnapshot, ...remaining]; + } + return sortedByStateItems; + }, [freezeSortOnOpen, sortSnapshot, sortByState, sortedByStateItems]); + + const filteredItems = useMemo(() => { + const search = query.trim().toLowerCase(); + if (!search) { + return orderedItems; + } + return orderedItems.filter((item) => item.label.toLowerCase().includes(search)); + }, [orderedItems, query]); const handleToggle = useCallback( - async (item) => { - if (!item || typeof onToggle !== 'function') { + async (item: NormalizedSelectionAssignmentItem) => { + if (!onToggle) { return; } setPending(true); try { await onToggle(item); setPending(false); - close(); + if (closeOnSelection) { + close(); + } } catch (error) { setPending(false); console.error('[selection-assignment] toggle failed', error); } }, - [onToggle, close], + [onToggle, close, closeOnSelection], ); const handleCreate = useCallback( - async (event) => { + async (event: React.FormEvent) => { event?.preventDefault?.(); - if (typeof onCreate !== 'function') { + if (!onCreate) { return; } const value = query.trim(); @@ -201,8 +309,8 @@ const SelectionAssignmentMenu = ({ type="submit" className="icon-button selection-assignment__add" disabled={!canSubmitCreate} - aria-label={createLabel || 'Add'} - title={createLabel || 'Add'} + aria-label={createLabel} + title={createLabel} >
+ ); }; diff --git a/frontend/src/preview/DocumentViewerLayout.jsx b/frontend/src/preview/DocumentViewerLayout.jsx deleted file mode 100644 index 1389c5a..0000000 --- a/frontend/src/preview/DocumentViewerLayout.jsx +++ /dev/null @@ -1,143 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import DocumentInfoPanel from '../documents/DocumentInfoPanel'; -import { DownloadIcon } from '../ui/icons'; - -const DocumentViewerLayout = ({ - document, - previewEntry, - summaryProps, - metadataPayload, - contentTabConfig, - resetKey, - classNamePrefix = 'document-viewer', - defaultTabId = 'details', - infoPanelProps = {}, - previewLoadingMessage = 'Preparing preview…', - layoutMode = 'split', -}) => { - const isStacked = layoutMode === 'stacked'; - - const previewContent = useMemo(() => { - if (!document || !previewEntry?.url) { - return null; - } - - const normalizedContentType = (previewEntry.contentType - || document.content_type - || '') - .toLowerCase(); - const isImage = normalizedContentType.startsWith('image/'); - const isPdf = normalizedContentType === 'application/pdf' - || normalizedContentType === 'application/x-pdf'; - - if (isImage) { - return ( - {`Preview - ); - } - - if (isPdf) { - return ( -
  Name Issued Added