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
@@ -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 ? (
-

event.preventDefault()}
- />
- ) : (
-
- )}
- {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 ? (
+

event.preventDefault()}
+ />
+ ) : (
+
+ )}
+
+ );
+};
+
+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();
+ }
+ }}
>
-

{
- setNaturalSize({
- width: event.currentTarget.naturalWidth || null,
- height: event.currentTarget.naturalHeight || null,
- });
- }}
- onClick={handleImageClick}
- style={imageStyle}
- />
-
- {navVisible ? (
-
-
),
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 (
) => ReactNode };
+
+type ContentState =
+ | { status: 'idle'; data: null; error: null }
+ | { status: 'loading'; data: null; error: null }
+ | { status: 'loaded'; data: string; error: null }
+ | { status: 'empty'; data: string; error: null }
+ | { status: 'unavailable'; data: null; error: null }
+ | { status: 'error'; data: null; error: unknown };
+
+export interface DocumentInfoPanelProps {
+ document: DocumentSummarySectionProps['document'];
+ summaryProps?: Omit;
+ metadataItems?: DocumentSummaryRow[];
+ metadataPayload?: Record;
+ metadataTabLabel?: string;
+ detailsTabLabel?: string;
+ contentConfig?: {
+ id?: string;
+ label?: string;
+ enabled?: boolean;
+ forceDisplay?: boolean;
+ loadContent?: (args: { signal: AbortSignal }) => Promise;
+ onCancel?: () => void;
+ loadingMessage?: string;
+ emptyMessage?: string;
+ unavailableMessage?: string;
+ errorMessage?: string;
+ renderContent?: (data: string) => ReactNode;
+ } | null;
+ activeTab?: string;
+ onTabChange?: (tabId: string) => void;
+ defaultTabId?: string;
+ resetKey?: string | number | null;
+ classNamePrefix?: string;
+ hideTabNavWhenSingle?: boolean;
+ summaryPlacement?: 'inline' | 'tabs';
+ summaryTabLabel?: string;
+ summaryTabId?: string;
+ leadingTabs?: PanelTab[];
+ trailingTabs?: PanelTab[];
+ tabsPlacement?: 'top' | 'bottom';
+ summaryLayout?: 'default' | 'compact';
+}
+
+const DocumentInfoPanel: React.FC = ({
document,
summaryProps = {},
metadataItems: metadataItemsProp,
@@ -30,7 +76,7 @@ const DocumentInfoPanel = ({
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
return metadataItemsProp;
}
- return buildDocumentMetadataItems(document);
+ return describeDocumentSummary(document);
}, [metadataItemsProp, document]);
const metadataPayload = useMemo(() => {
@@ -42,13 +88,14 @@ const DocumentInfoPanel = ({
const contentConfig = contentConfigProp || null;
const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true));
- const showContentTab = Boolean(contentConfig && ((contentConfig.forceDisplay ?? contentEnabled)));
+ const loadContent = contentConfig?.loadContent ?? null;
+ const showContentTab = Boolean(contentConfig && (contentConfig.forceDisplay ?? contentEnabled));
- const [contentState, setContentState] = useState(() => {
+ const [contentState, setContentState] = useState(() => {
if (!contentConfig) {
return null;
}
- if (!contentEnabled || typeof contentConfig.loadContent !== 'function') {
+ if (!contentEnabled || !loadContent) {
return { status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null };
}
return { status: 'idle', data: null, error: null };
@@ -60,7 +107,7 @@ const DocumentInfoPanel = ({
return undefined;
}
- if (!contentEnabled || typeof contentConfig.loadContent !== 'function') {
+ if (!contentEnabled || !loadContent) {
setContentState({ status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null });
return undefined;
}
@@ -70,7 +117,7 @@ const DocumentInfoPanel = ({
setContentState({ status: 'loading', data: null, error: null });
- Promise.resolve(contentConfig.loadContent({ signal: controller.signal }))
+ Promise.resolve(loadContent({ signal: controller.signal }))
.then((result) => {
if (cancelled) {
return;
@@ -97,23 +144,22 @@ const DocumentInfoPanel = ({
controller.abort();
contentConfig.onCancel?.();
};
- }, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]);
+ }, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey, loadContent]);
const renderSummarySection = useCallback(() => (
- ), [document, summaryLayout, summaryProps, metadataItems]);
+ ), [document, summaryLayout, summaryProps]);
const renderDetailsSection = useCallback(() => (
{metadataItems.length ? (
- {metadataItems.map(({ label, value }) => (
-
+ {metadataItems.map(({ key, label, value }) => (
+
- {label}
- {value || '—'}
@@ -144,14 +190,14 @@ const DocumentInfoPanel = ({
const normalizedLeadingTabs = useMemo(
() => (Array.isArray(leadingTabs)
- ? leadingTabs.filter((tab) => tab && tab.id && tab.label)
+ ? leadingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
: []),
[leadingTabs],
);
const normalizedTrailingTabs = useMemo(
() => (Array.isArray(trailingTabs)
- ? trailingTabs.filter((tab) => tab && tab.id && tab.label)
+ ? trailingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
: []),
[trailingTabs],
);
@@ -166,7 +212,7 @@ const DocumentInfoPanel = ({
: null;
const visibleTabs = useMemo(() => {
- const tabsList = [];
+ const tabsList: PanelTab[] = [];
if (normalizedLeadingTabs.length) {
tabsList.push(...normalizedLeadingTabs);
@@ -251,17 +297,17 @@ const DocumentInfoPanel = ({
}
if (metadataPayload) {
- tabsList.push({
- id: 'metadata',
- label: metadataTabLabel,
- render: () => (
-
-
- {JSON.stringify(metadataPayload, null, 2)}
-
-
- ),
- });
+ tabsList.push({
+ id: 'metadata',
+ label: metadataTabLabel,
+ render: () => (
+
+
+ {JSON.stringify(metadataPayload, null, 2)}
+
+
+ ),
+ });
}
if (normalizedTrailingTabs.length) {
@@ -295,18 +341,14 @@ const DocumentInfoPanel = ({
return visibleTabs[0].id;
}, [visibleTabs, defaultTabId]);
- const renderTabContent = (tab, context = {}) => {
+ const renderTabContent = (tab?: PanelTab | null, context: Record
= {}) => {
if (!tab) {
return null;
}
- if (typeof tab.render === 'function') {
- return tab.render(context);
+ if (!tab.render) {
+ return null;
}
- if (tab.component) {
- const TabComponent = tab.component;
- return ;
- }
- return React.isValidElement(tab.render) ? tab.render : null;
+ return tab.render(context);
};
const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null;
diff --git a/frontend/src/documents/DocumentSummarySection.jsx b/frontend/src/documents/DocumentSummarySection.tsx
similarity index 52%
rename from frontend/src/documents/DocumentSummarySection.jsx
rename to frontend/src/documents/DocumentSummarySection.tsx
index 1d1319b..e73bd75 100644
--- a/frontend/src/documents/DocumentSummarySection.jsx
+++ b/frontend/src/documents/DocumentSummarySection.tsx
@@ -1,13 +1,86 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
-import { EditIcon, IconX, PlusIcon } from '../ui/icons';
-import SelectionAssignmentMenu from './SelectionAssignmentMenu';
+import React, { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
+import { EditIcon, IconX, CheckIcon, PlusIcon } from '../ui/icons';
+import SelectionAssignmentMenu, {
+ SelectionAssignmentMenuItem,
+ type NormalizedSelectionAssignmentItem,
+} from './SelectionAssignmentMenu';
import { getTagColorStyle } from '../utils/colors';
import {
formatDate,
toDateInputValue,
toIssuedTimestamp,
} from '../utils/date';
-import { describeDocumentSummary } from './documentSummary';
+import { describeDocumentSummary, type DocumentSummaryRow } from './documentSummary';
+import { isPlainObject } from '../utils/typeGuards';
+
+type Identifier = string | number;
+
+interface TagEntry {
+ id?: Identifier;
+ label?: string;
+ color?: string | null;
+}
+
+interface CorrespondentEntry {
+ id?: Identifier;
+ name?: string;
+ count?: number;
+}
+
+interface DocumentLike {
+ id?: Identifier;
+ title?: string;
+ issued_at?: string | null;
+ current_version?: { version_number?: number } | null;
+ tags?: TagEntry[];
+ correspondents?: CorrespondentEntry[];
+ [key: string]: unknown;
+}
+
+interface TagSectionProps {
+ tags?: TagEntry[];
+ onRemove?: (tag: TagEntry) => void;
+ onAdd?: (payload: { value: string; option?: unknown; input?: unknown }) => void;
+ emptyMessage?: string;
+ addPlaceholder?: string;
+ addButtonLabel?: string;
+ datalistOptions?: Array;
+ className?: string;
+}
+
+interface CorrespondentSectionProps {
+ entries?: CorrespondentEntry[];
+ onRemove?: (entry: CorrespondentEntry) => void;
+ onAdd?: (payload: { name: string; option?: unknown; input?: unknown }) => void;
+ showCount?: boolean;
+ addPlaceholder?: string;
+ addButtonLabel?: string;
+ datalistOptions?: Array;
+ className?: string;
+}
+
+export interface DocumentSummarySectionProps {
+ document?: DocumentLike | null;
+ tagLookupById?: Map;
+ tagOptions?: SelectionAssignmentMenuItem[];
+ onTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
+ onTagRemove?: (docId: Identifier | undefined, tagId: Identifier | undefined) => void;
+ correspondents?: CorrespondentEntry[];
+ correspondentOptions?: SelectionAssignmentMenuItem[];
+ onCorrespondentAdd?: (payload: { document: DocumentLike; name: string; option?: unknown }) => void;
+ onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void;
+ onUpdateTitle?: (docId: Identifier | undefined, title: string) => Promise | boolean;
+ onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise | boolean;
+ layout?: 'default' | 'compact';
+}
+
+interface MetaItem {
+ key: string;
+ label: string;
+ valueContent?: React.ReactNode | null;
+ fallbackValue?: string | null;
+ error?: string | null;
+}
export const sortCorrespondents = (entries = []) =>
entries
@@ -18,7 +91,7 @@ export const sortCorrespondents = (entries = []) =>
export const buildCorrespondentOptions = (entries = []) => {
const seen = new Set();
return entries.reduce((options, entry) => {
- const name = typeof entry?.name === 'string' ? entry.name.trim() : '';
+ const name = entry?.name?.trim?.() || '';
if (!name) {
return options;
}
@@ -32,32 +105,54 @@ export const buildCorrespondentOptions = (entries = []) => {
}, []);
};
-const normalizeOptions = (options) => (Array.isArray(options) ? options : []);
+const normalizeOptions = (options?: T[] | null): T[] => (Array.isArray(options) ? options : []);
-const normalizeQuickAddOption = (option) => {
+interface QuickAddOption {
+ id?: Identifier;
+ label?: string;
+ name?: string;
+ [key: string]: unknown;
+}
+
+interface QuickAddEntry {
+ id: Identifier | string;
+ label: string;
+ original: QuickAddOption | string;
+}
+
+const resolveOptionName = (source?: QuickAddOption | string | null): string => {
+ if (!source) {
+ return '';
+ }
+ if (isPlainObject(source)) {
+ const raw = source.name ?? source.label ?? '';
+ return `${raw}`.trim();
+ }
+ return `${source}`.trim();
+};
+
+const normalizeQuickAddOption = (option?: QuickAddOption | string | null): QuickAddEntry | null => {
if (option == null) {
return null;
}
- if (typeof option === 'string') {
- const label = option.trim();
- return label ? { id: label, label, original: option } : null;
- }
- const label = typeof option.label === 'string'
- ? option.label.trim()
- : typeof option.name === 'string'
- ? option.name.trim()
- : '';
+ const label = (() => {
+ if (isPlainObject(option)) {
+ const sourceLabel = option.label ?? option.name ?? '';
+ return `${sourceLabel}`.trim();
+ }
+ return `${option}`.trim();
+ })();
if (!label) {
return null;
}
return {
- id: option.id ?? label,
+ id: isPlainObject(option) && option.id ? option.id : label,
label,
original: option,
};
};
-export const TagSection = ({
+export const TagSection: React.FC = ({
tags = [],
onRemove,
onAdd,
@@ -68,16 +163,14 @@ export const TagSection = ({
className,
}) => {
const handleCreate = useCallback(
- (label) => onAdd?.({ value: label, input: null }),
+ (label: string) => onAdd?.({ value: label, input: null }),
[onAdd],
);
const handleSelect = useCallback(
- (option) => {
+ (option: { label?: string; name?: string } | string | null) => {
if (!onAdd) return;
- const label =
- (option && typeof option === 'object' && option.label) ||
- (typeof option === 'string' ? option : '');
+ const label = resolveOptionName(option as QuickAddOption | string | null);
if (!label) {
return;
}
@@ -90,14 +183,14 @@ export const TagSection = ({
() =>
normalizeOptions(datalistOptions)
.map((option) => normalizeQuickAddOption(option))
- .filter(Boolean),
+ .filter((option): option is QuickAddEntry => Boolean(option)),
[datalistOptions],
);
const containerClass = className ? `tag-list ${className}` : 'tag-list';
const showQuickAdd = Boolean(onAdd);
- const assignmentItems = useMemo(() => {
- const map = new Map();
+ const assignmentItems = useMemo(() => {
+ const map = new Map();
normalizedOptions.forEach((option) => {
const label = option?.label?.trim();
@@ -117,7 +210,7 @@ export const TagSection = ({
});
tags.forEach((tag) => {
- const label = typeof tag?.label === 'string' ? tag.label.trim() : '';
+ const label = tag?.label?.trim?.() || '';
if (!label) {
return;
}
@@ -125,8 +218,10 @@ export const TagSection = ({
const payload = { id: tag.id, label, color: tag.color ?? null };
if (map.has(key)) {
const entry = map.get(key);
- entry.state = 'all';
- entry.payload = payload;
+ if (entry) {
+ entry.state = 'all';
+ entry.payload = payload;
+ }
return;
}
map.set(key, {
@@ -141,14 +236,21 @@ export const TagSection = ({
}, [normalizedOptions, tags]);
const handleAssignmentSelect = useCallback(
- (item) => {
+ (item: NormalizedSelectionAssignmentItem) => {
if (!item) {
return;
}
+ if (item.state === 'all' && onRemove) {
+ const payload = isPlainObject(item.payload)
+ ? (item.payload as TagEntry)
+ : tags.find((tag) => (tag.id ?? tag.label) === item.id) ?? { id: item.id, label: item.label };
+ onRemove(payload);
+ return;
+ }
const payload = item.payload ?? { label: item.label };
handleSelect(payload);
},
- [handleSelect],
+ [handleSelect, onRemove, tags],
);
return (
@@ -185,11 +287,9 @@ export const TagSection = ({
showCounts={false}
positionStrategy="fixed"
triggerClassName="quick-add__chip quick-add__trigger"
- triggerContent={(
-
- Add tag
-
- )}
+ triggerContent={}
+ closeOnSelection={false}
+ freezeSortOnOpen
/>
) : null}
{!tags.length && !showQuickAdd ? {emptyMessage} : null}
@@ -197,7 +297,7 @@ export const TagSection = ({
);
};
-export const CorrespondentSection = ({
+export const CorrespondentSection: React.FC = ({
entries = [],
onRemove,
onAdd,
@@ -208,7 +308,7 @@ export const CorrespondentSection = ({
className,
}) => {
const handleCreate = useCallback(
- (name) => onAdd?.({ name, input: null }),
+ (name: string) => onAdd?.({ name, input: null }),
[onAdd],
);
@@ -216,15 +316,15 @@ export const CorrespondentSection = ({
() =>
normalizeOptions(datalistOptions)
.map((option) => normalizeQuickAddOption(option))
- .filter(Boolean),
+ .filter((option): option is QuickAddEntry => Boolean(option)),
[datalistOptions],
);
const hasEntries = entries && entries.length > 0;
const showQuickAdd = Boolean(onAdd);
const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
- const assignmentItems = useMemo(() => {
- const map = new Map();
+ const assignmentItems = useMemo(() => {
+ const map = new Map();
normalizedOptions.forEach((option) => {
const label = option?.label?.trim();
@@ -244,7 +344,7 @@ export const CorrespondentSection = ({
});
entries.forEach((entry) => {
- const label = typeof entry?.name === 'string' ? entry.name.trim() : '';
+ const label = entry?.name?.trim?.() || '';
if (!label) {
return;
}
@@ -252,8 +352,10 @@ export const CorrespondentSection = ({
const payload = { id: entry.id, name: label };
if (map.has(key)) {
const item = map.get(key);
- item.state = 'all';
- item.payload = payload;
+ if (item) {
+ item.state = 'all';
+ item.payload = payload;
+ }
return;
}
map.set(key, {
@@ -268,24 +370,31 @@ export const CorrespondentSection = ({
}, [normalizedOptions, entries]);
const handleAssignmentSelect = useCallback(
- (item) => {
- if (!onAdd || !item) {
+ (item: NormalizedSelectionAssignmentItem) => {
+ if (!item) {
return;
}
- const source = item.payload ?? item;
- const resolvedName =
- (source && typeof source.name === 'string' && source.name.trim())
- || (typeof source === 'string' ? source.trim() : '')
- || (source && typeof source.label === 'string' ? source.label.trim() : '');
+ if (item.state === 'all' && onRemove) {
+ const payload = isPlainObject(item.payload)
+ ? (item.payload as CorrespondentEntry)
+ : entries.find((entry) => (entry.id ?? entry.name) === item.id) ?? { id: item.id, name: item.label };
+ onRemove(payload);
+ return;
+ }
+ if (!onAdd) {
+ return;
+ }
+ const source = (item.payload ?? item) as QuickAddOption | string | null;
+ const resolvedName = resolveOptionName(source);
if (!resolvedName) {
return;
}
- const payload = typeof source === 'object'
+ const payload = isPlainObject(source)
? { ...source, name: resolvedName }
: { id: null, name: resolvedName };
onAdd({ name: resolvedName, option: payload, input: null });
},
- [onAdd],
+ [entries, onAdd, onRemove],
);
return (
@@ -326,18 +435,16 @@ export const CorrespondentSection = ({
showCounts={false}
positionStrategy="fixed"
triggerClassName="quick-add__chip quick-add__trigger"
- triggerContent={(
-
- Add correspondent
-
- )}
+ triggerContent={}
+ closeOnSelection={false}
+ freezeSortOnOpen
/>
) : null}
);
};
-const DocumentSummarySection = ({
+const DocumentSummarySection: React.FC = ({
document,
tagLookupById = new Map(),
tagOptions = [],
@@ -350,20 +457,9 @@ const DocumentSummarySection = ({
onUpdateTitle,
onUpdateIssued,
layout = 'default',
- detailItems = [],
}) => {
const isCompactLayout = layout === 'compact';
- const summary = useMemo(() => {
- if (!document) {
- return {
- title: '',
- originalName: '',
- sizeLabel: '—',
- pageCount: null,
- };
- }
- return describeDocumentSummary(document);
- }, [document]);
+ const summaryRows = useMemo(() => describeDocumentSummary(document), [document]);
const issuedDateLabel = useMemo(
() => formatDate(document?.issued_at, { fallback: null }),
[document?.issued_at],
@@ -390,8 +486,8 @@ const DocumentSummarySection = ({
return sortCorrespondents(document?.correspondents || []);
}, [correspondents, document?.correspondents]);
- const metaRows = useMemo(() => {
- const rows = [];
+ const extraSummaryRows = useMemo(() => {
+ const rows: DocumentSummaryRow[] = [];
const currentVersionNumber = document?.current_version?.version_number;
if (Number.isFinite(currentVersionNumber)) {
rows.push({
@@ -400,17 +496,8 @@ const DocumentSummarySection = ({
value: `#${currentVersionNumber}`,
});
}
-
- if (summary.sizeLabel && summary.sizeLabel !== '—') {
- rows.push({ key: 'size', label: 'Size', value: summary.sizeLabel });
- }
-
- if (Number.isFinite(summary.pageCount)) {
- rows.push({ key: 'pages', label: 'Pages', value: String(summary.pageCount) });
- }
-
return rows;
- }, [document?.current_version?.version_number, summary]);
+ }, [document?.current_version?.version_number]);
const [titleDraft, setTitleDraft] = useState('');
const [titleSaving, setTitleSaving] = useState(false);
@@ -437,7 +524,7 @@ const DocumentSummarySection = ({
const startTitleEdit = useCallback(() => {
if (!editableTitle || !document) return;
setIsTitleEditing(true);
- setTitleDraft(document.title);
+ setTitleDraft(document.title || '');
setTitleError(null);
}, [document, editableTitle]);
@@ -449,9 +536,9 @@ const DocumentSummarySection = ({
}, []);
const submitTitleEdit = useCallback(
- async (event) => {
+ async (event: FormEvent) => {
event.preventDefault();
- if (!editableTitle || !document) return;
+ if (!editableTitle || !document || !onUpdateTitle) return;
const trimmed = titleDraft.trim();
if (!trimmed) {
setTitleError('Title cannot be empty.');
@@ -487,9 +574,9 @@ const DocumentSummarySection = ({
}, []);
const submitIssuedEdit = useCallback(
- async (event) => {
+ async (event: FormEvent) => {
event.preventDefault();
- if (!editableIssued || !document) return;
+ if (!editableIssued || !document || !onUpdateIssued) return;
const normalizedValue = issuedDraft ? toIssuedTimestamp(issuedDraft, document.issued_at) : null;
setIssuedSaving(true);
try {
@@ -510,57 +597,60 @@ const DocumentSummarySection = ({
return null;
}
- const TitleSection = () => (
- editableTitle && isTitleEditing ? (
-
- ) : (
- <>
- {summary.title}
- {editableTitle ? (
-
-
-
- ) : null}
- >
- )
+ const renderTitleEditForm = (extraClassName?: string) => (
+
);
+ 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 ? (
-
- ) : (
- <>
-
{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();
}}
>
- |
+ |
Name |
Issued |
Added |
@@ -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