feat(ui): migrate hardcoded strings to i18n for translations
This commit is contained in:
parent
c97e54d664
commit
1cc4c5c813
|
|
@ -19,10 +19,14 @@
|
|||
"axios": "^1.3.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"i18next": "^25.5.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
|
|
@ -101,7 +105,6 @@
|
|||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
|
|
@ -448,7 +451,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -472,7 +474,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -535,7 +536,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
|
|
@ -579,7 +579,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
||||
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
|
|
@ -1190,7 +1189,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
|
||||
"integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/core-downloads-tracker": "^7.3.4",
|
||||
|
|
@ -1301,7 +1299,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.3.tgz",
|
||||
"integrity": "sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/private-theming": "^7.3.3",
|
||||
|
|
@ -1880,7 +1877,8 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
|
|
@ -1932,8 +1930,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
|
||||
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai-subset": {
|
||||
"version": "1.3.6",
|
||||
|
|
@ -1958,7 +1955,6 @@
|
|||
"integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.8.0"
|
||||
}
|
||||
|
|
@ -1980,7 +1976,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
|
@ -1991,7 +1986,6 @@
|
|||
"integrity": "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -2136,6 +2130,7 @@
|
|||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
|
|
@ -2265,7 +2260,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001718",
|
||||
"electron-to-chromium": "^1.5.160",
|
||||
|
|
@ -2364,6 +2358,7 @@
|
|||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
|
|
@ -2420,6 +2415,7 @@
|
|||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
|
|
@ -2432,7 +2428,8 @@
|
|||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
|
|
@ -2493,6 +2490,15 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.12"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
|
|
@ -2539,7 +2545,6 @@
|
|||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
|
|
@ -2616,7 +2621,8 @@
|
|||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
|
|
@ -2984,6 +2990,7 @@
|
|||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
|
@ -3055,6 +3062,15 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
|
|
@ -3083,6 +3099,55 @@
|
|||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.5.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.5.3.tgz",
|
||||
"integrity": "sha512-joFqorDeQ6YpIXni944upwnuHBf5IoPMuqAchGVeQLdWC2JOjxgM9V8UGLhNIIH/Q8QleRxIi0BSRQehSrDLcg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com/i18next.html"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
|
||||
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
|
||||
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-fetch": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
|
|
@ -3162,7 +3227,6 @@
|
|||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
|
|
@ -3285,6 +3349,7 @@
|
|||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
|
|
@ -3374,6 +3439,48 @@
|
|||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.19",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||
|
|
@ -3510,7 +3617,6 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -3589,7 +3695,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
|
|
@ -3679,7 +3784,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -3689,7 +3793,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
|
|
@ -3730,6 +3833,32 @@
|
|||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.0.0.tgz",
|
||||
"integrity": "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 25.5.2",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
|
||||
|
|
@ -4127,6 +4256,7 @@
|
|||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
|
|
@ -4270,7 +4400,7 @@
|
|||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
|
@ -4353,7 +4483,6 @@
|
|||
"integrity": "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -5520,6 +5649,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -30,10 +30,14 @@
|
|||
"axios": "^1.3.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"i18next": "^25.5.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -27,6 +27,7 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTheme as useMuiTheme } from '@mui/material/styles';
|
||||
import { api, ErrorHelper, ErrorCodes } from '../../services/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LoginFormData {
|
||||
username: string;
|
||||
|
|
@ -42,7 +43,8 @@ const Login: React.FC = () => {
|
|||
const navigate = useNavigate();
|
||||
const { mode } = useTheme();
|
||||
const theme = useMuiTheme();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
|
|
@ -62,20 +64,20 @@ const Login: React.FC = () => {
|
|||
|
||||
// Handle specific login errors
|
||||
if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_INVALID_CREDENTIALS)) {
|
||||
setError('Invalid username or password. Please check your credentials and try again.');
|
||||
setError(t('auth.errors.invalidCredentials'));
|
||||
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_ACCOUNT_DISABLED)) {
|
||||
setError('Your account has been disabled. Please contact an administrator for assistance.');
|
||||
setError(t('auth.errors.accountDisabled'));
|
||||
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_NOT_FOUND)) {
|
||||
setError('No account found with this username. Please check your username or contact support.');
|
||||
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
setError(t('auth.errors.userNotFound'));
|
||||
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
ErrorHelper.isErrorCode(err, ErrorCodes.USER_TOKEN_EXPIRED)) {
|
||||
setError('Your session has expired. Please try logging in again.');
|
||||
setError(t('auth.errors.sessionExpired'));
|
||||
} else if (errorInfo.category === 'network') {
|
||||
setError('Network error. Please check your connection and try again.');
|
||||
setError(t('auth.errors.networkError'));
|
||||
} else if (errorInfo.category === 'server') {
|
||||
setError('Server error. Please try again later or contact support if the problem persists.');
|
||||
setError(t('auth.errors.serverError'));
|
||||
} else {
|
||||
setError(errorInfo.message || 'Failed to log in. Please check your credentials.');
|
||||
setError(errorInfo.message || t('auth.errors.loginFailed'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -99,13 +101,13 @@ const Login: React.FC = () => {
|
|||
|
||||
// Handle specific OIDC errors
|
||||
if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_OIDC_AUTH_FAILED)) {
|
||||
setError('OIDC authentication failed. Please check with your administrator.');
|
||||
setError(t('auth.errors.oidcAuthFailed'));
|
||||
} else if (ErrorHelper.isErrorCode(err, ErrorCodes.USER_AUTH_PROVIDER_NOT_CONFIGURED)) {
|
||||
setError('OIDC is not configured on this server. Please use username/password login.');
|
||||
setError(t('auth.errors.oidcNotConfigured'));
|
||||
} else if (errorInfo.category === 'network') {
|
||||
setError('Network error. Please check your connection and try again.');
|
||||
setError(t('auth.errors.networkError'));
|
||||
} else {
|
||||
setError(errorInfo.message || 'Failed to initiate OIDC login. Please try again.');
|
||||
setError(errorInfo.message || t('auth.errors.oidcInitFailed'));
|
||||
}
|
||||
setOidcLoading(false);
|
||||
}
|
||||
|
|
@ -161,18 +163,18 @@ const Login: React.FC = () => {
|
|||
: '0 4px 12px rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
>
|
||||
Welcome to Readur
|
||||
{t('common.welcome', { appName: t('common.appName') })}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: mode === 'light'
|
||||
color: mode === 'light'
|
||||
? 'rgba(255, 255, 255, 0.8)'
|
||||
: 'rgba(255, 255, 255, 0.9)',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
Your intelligent document management platform
|
||||
{t('auth.intelligentDocumentPlatform')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
|
@ -204,7 +206,7 @@ const Login: React.FC = () => {
|
|||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
Sign in to your account
|
||||
{t('auth.signInToAccount')}
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
|
|
@ -216,10 +218,10 @@ const Login: React.FC = () => {
|
|||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
label={t('auth.username')}
|
||||
margin="normal"
|
||||
{...register('username', {
|
||||
required: 'Username is required',
|
||||
required: t('auth.usernameRequired'),
|
||||
})}
|
||||
error={!!errors.username}
|
||||
helperText={errors.username?.message}
|
||||
|
|
@ -235,11 +237,11 @@ const Login: React.FC = () => {
|
|||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
label={t('auth.password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
margin="normal"
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
required: t('auth.passwordRequired'),
|
||||
})}
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
|
|
@ -288,7 +290,7 @@ const Login: React.FC = () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
{loading ? t('auth.signingIn') : t('auth.signIn')}
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
|
|
@ -310,14 +312,14 @@ const Login: React.FC = () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
px: 2,
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
px: 2,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
or
|
||||
{t('common.or')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
|
@ -348,7 +350,7 @@ const Login: React.FC = () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
{oidcLoading ? 'Redirecting...' : 'Sign in with OIDC'}
|
||||
{oidcLoading ? t('auth.redirecting') : t('auth.signInWithOIDC')}
|
||||
</Button>
|
||||
|
||||
<Box sx={{ textAlign: 'center', mt: 2 }}>
|
||||
|
|
@ -363,12 +365,12 @@ const Login: React.FC = () => {
|
|||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: mode === 'light'
|
||||
color: mode === 'light'
|
||||
? 'rgba(255, 255, 255, 0.7)'
|
||||
: 'rgba(255, 255, 255, 0.8)',
|
||||
}}
|
||||
>
|
||||
© 2026 Readur. Powered by advanced OCR and AI technology.
|
||||
{t('common.copyright')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import api, { documentService } from '../../services/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
|
|
@ -214,7 +215,8 @@ const StatsCard: React.FC<StatsCardProps> = ({ title, value, subtitle, icon: Ico
|
|||
const RecentDocuments: React.FC<RecentDocumentsProps> = ({ documents = [] }) => {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Ensure documents is always an array
|
||||
const safeDocuments = Array.isArray(documents) ? documents : [];
|
||||
|
||||
|
|
@ -256,7 +258,7 @@ const RecentDocuments: React.FC<RecentDocumentsProps> = ({ documents = [] }) =>
|
|||
}}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Typography variant="h6" sx={{
|
||||
<Typography variant="h6" sx={{
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.025em',
|
||||
background: theme.palette.mode === 'light'
|
||||
|
|
@ -266,10 +268,10 @@ const RecentDocuments: React.FC<RecentDocumentsProps> = ({ documents = [] }) =>
|
|||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}>
|
||||
Recent Documents
|
||||
{t('dashboard.recentDocuments.title')}
|
||||
</Typography>
|
||||
<Chip
|
||||
label="View All"
|
||||
label={t('dashboard.recentDocuments.viewAll')}
|
||||
onClick={() => navigate('/documents')}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
|
|
@ -302,15 +304,15 @@ const RecentDocuments: React.FC<RecentDocumentsProps> = ({ documents = [] }) =>
|
|||
}}>
|
||||
<DocumentIcon sx={{ fontSize: 32, color: '#6366f1' }} />
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{
|
||||
<Typography variant="body1" sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 500,
|
||||
mb: 1,
|
||||
}}>
|
||||
No documents yet
|
||||
{t('dashboard.recentDocuments.noDocuments')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload your first document to get started
|
||||
{t('dashboard.recentDocuments.uploadFirst')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
|
|
@ -403,25 +405,26 @@ const RecentDocuments: React.FC<RecentDocumentsProps> = ({ documents = [] }) =>
|
|||
const QuickActions: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const actions: QuickAction[] = [
|
||||
{
|
||||
title: 'Upload Documents',
|
||||
description: 'Add new files for OCR processing',
|
||||
title: t('dashboard.quickActions.upload.title'),
|
||||
description: t('dashboard.quickActions.upload.description'),
|
||||
icon: UploadIcon,
|
||||
color: '#6366f1',
|
||||
path: '/upload',
|
||||
},
|
||||
{
|
||||
title: 'Search Library',
|
||||
description: 'Find documents by content or metadata',
|
||||
title: t('dashboard.quickActions.search.title'),
|
||||
description: t('dashboard.quickActions.search.description'),
|
||||
icon: SearchIcon,
|
||||
color: '#10b981',
|
||||
path: '/search',
|
||||
},
|
||||
{
|
||||
title: 'Browse Documents',
|
||||
description: 'View and manage your document library',
|
||||
title: t('dashboard.quickActions.browse.title'),
|
||||
description: t('dashboard.quickActions.browse.description'),
|
||||
icon: SearchableIcon,
|
||||
color: '#f59e0b',
|
||||
path: '/documents',
|
||||
|
|
@ -440,7 +443,7 @@ const QuickActions: React.FC = () => {
|
|||
borderRadius: 3,
|
||||
}}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography variant="h6" sx={{
|
||||
<Typography variant="h6" sx={{
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.025em',
|
||||
background: theme.palette.mode === 'light'
|
||||
|
|
@ -451,7 +454,7 @@ const QuickActions: React.FC = () => {
|
|||
WebkitTextFillColor: 'transparent',
|
||||
mb: 3,
|
||||
}}>
|
||||
Quick Actions
|
||||
{t('dashboard.quickActions.title')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{actions.map((action) => (
|
||||
|
|
@ -523,6 +526,7 @@ const Dashboard: React.FC = () => {
|
|||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalDocuments: 0,
|
||||
|
|
@ -621,8 +625,8 @@ const Dashboard: React.FC = () => {
|
|||
<Box>
|
||||
{/* Welcome Header */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" sx={{
|
||||
fontWeight: 800,
|
||||
<Typography variant="h4" sx={{
|
||||
fontWeight: 800,
|
||||
mb: 1,
|
||||
letterSpacing: '-0.025em',
|
||||
background: theme.palette.mode === 'light'
|
||||
|
|
@ -632,14 +636,14 @@ const Dashboard: React.FC = () => {
|
|||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}>
|
||||
Welcome back, {user?.username}! 👋
|
||||
{t('common.welcomeBack', { username: user?.username })}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.025em',
|
||||
}}>
|
||||
Here's what's happening with your documents today.
|
||||
{t('dashboard.greeting')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
|
@ -647,42 +651,52 @@ const Dashboard: React.FC = () => {
|
|||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
<Grid item xs={12} sm={6} lg={3}>
|
||||
<StatsCard
|
||||
title="Total Documents"
|
||||
title={t('dashboard.stats.totalDocuments.title')}
|
||||
value={loading ? '...' : stats.totalDocuments}
|
||||
subtitle="Files in your library"
|
||||
subtitle={t('dashboard.stats.totalDocuments.subtitle')}
|
||||
icon={DocumentIcon}
|
||||
color="#6366f1"
|
||||
trend={stats.totalDocuments > 0 ? `${stats.totalDocuments} total` : 'No documents yet'}
|
||||
trend={stats.totalDocuments > 0
|
||||
? t('dashboard.stats.totalDocuments.trend', { count: stats.totalDocuments })
|
||||
: t('dashboard.stats.totalDocuments.trendEmpty')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}>
|
||||
<StatsCard
|
||||
title="Storage Used"
|
||||
title={t('dashboard.stats.storageUsed.title')}
|
||||
value={loading ? '...' : formatBytes(stats.totalSize)}
|
||||
subtitle="Total file size"
|
||||
subtitle={t('dashboard.stats.storageUsed.subtitle')}
|
||||
icon={StorageIcon}
|
||||
color="#10b981"
|
||||
trend={stats.totalSize > 0 ? `${formatBytes(stats.totalSize)} used` : 'No storage used'}
|
||||
trend={stats.totalSize > 0
|
||||
? t('dashboard.stats.storageUsed.trend', { size: formatBytes(stats.totalSize) })
|
||||
: t('dashboard.stats.storageUsed.trendEmpty')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}>
|
||||
<StatsCard
|
||||
title="OCR Processed"
|
||||
title={t('dashboard.stats.ocrProcessed.title')}
|
||||
value={loading ? '...' : stats.ocrProcessed}
|
||||
subtitle="Text extracted documents"
|
||||
subtitle={t('dashboard.stats.ocrProcessed.subtitle')}
|
||||
icon={OcrIcon}
|
||||
color="#f59e0b"
|
||||
trend={stats.totalDocuments > 0 ? `${Math.round((stats.ocrProcessed / stats.totalDocuments) * 100)}% completion` : '0% completion'}
|
||||
trend={stats.totalDocuments > 0
|
||||
? t('dashboard.stats.ocrProcessed.trend', {
|
||||
percentage: Math.round((stats.ocrProcessed / stats.totalDocuments) * 100)
|
||||
})
|
||||
: t('dashboard.stats.ocrProcessed.trendEmpty')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}>
|
||||
<StatsCard
|
||||
title="Searchable"
|
||||
title={t('dashboard.stats.searchable.title')}
|
||||
value={loading ? '...' : stats.searchablePages}
|
||||
subtitle="Ready for search"
|
||||
subtitle={t('dashboard.stats.searchable.subtitle')}
|
||||
icon={SearchableIcon}
|
||||
color="#8b5cf6"
|
||||
trend={stats.searchablePages > 0 ? `${stats.searchablePages} indexed` : 'Nothing indexed yet'}
|
||||
trend={stats.searchablePages > 0
|
||||
? t('dashboard.stats.searchable.trend', { count: stats.searchablePages })
|
||||
: t('dashboard.stats.searchable.trendEmpty')}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
AccessTime as TimeIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { documentService, SearchRequest, EnhancedDocument, SearchResponse } from '../../services/api';
|
||||
|
||||
interface GlobalSearchBarProps {
|
||||
|
|
@ -43,6 +44,7 @@ interface GlobalSearchBarProps {
|
|||
const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState<string>('');
|
||||
const [results, setResults] = useState<EnhancedDocument[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
|
@ -333,7 +335,7 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|||
<TextField
|
||||
ref={searchInputRef}
|
||||
size="small"
|
||||
placeholder="Search documents..."
|
||||
placeholder={t('search.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
|
|
@ -481,12 +483,12 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|||
}}>
|
||||
<CircularProgress size={20} thickness={4} sx={{ color: '#6366f1' }} />
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{
|
||||
<Typography variant="body2" sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.025em',
|
||||
}}>
|
||||
{isTyping ? 'Searching as you type...' : 'Searching...'}
|
||||
{isTyping ? t('search.searchingAsYouType') : t('search.searching')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
|
@ -510,8 +512,8 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|||
)}
|
||||
|
||||
{!loading && !isTyping && query && results.length === 0 && (
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.02) 0%, rgba(139,92,246,0.02) 100%)',
|
||||
}}>
|
||||
|
|
@ -521,7 +523,7 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|||
letterSpacing: '0.025em',
|
||||
mb: 1,
|
||||
}}>
|
||||
No documents found for "{query}"
|
||||
{t('search.noDocumentsFound', { query })}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{
|
||||
color: 'text.secondary',
|
||||
|
|
@ -529,7 +531,7 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|||
mb: 2,
|
||||
display: 'block',
|
||||
}}>
|
||||
Press Enter to search with advanced options
|
||||
{t('search.pressEnterAdvanced')}
|
||||
</Typography>
|
||||
|
||||
{/* Smart suggestions for no results */}
|
||||
|
|
@ -544,7 +546,7 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|||
mb: 1.5,
|
||||
display: 'block',
|
||||
}}>
|
||||
Try these suggestions:
|
||||
{t('search.trySuggestions')}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} justifyContent="center" flexWrap="wrap">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
|
|
@ -593,7 +595,7 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|||
textTransform: 'uppercase',
|
||||
fontSize: '0.7rem',
|
||||
}}>
|
||||
Quick Results
|
||||
{t('search.quickResults')}
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
px: 1.5,
|
||||
|
|
@ -606,7 +608,7 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
}}>
|
||||
{results.length} found
|
||||
{t('search.resultsCount', { count: results.length })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
|
@ -763,7 +765,7 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|||
transition: 'color 0.2s ease-in-out',
|
||||
}}
|
||||
>
|
||||
View all results for "{query}"
|
||||
{t('search.viewAllResults', { query })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -785,7 +787,7 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|||
textTransform: 'uppercase',
|
||||
fontSize: '0.7rem',
|
||||
}}>
|
||||
Recent Searches
|
||||
{t('search.recentSearches')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<List sx={{ py: 0 }}>
|
||||
|
|
@ -846,7 +848,7 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|||
letterSpacing: '0.025em',
|
||||
mb: 1,
|
||||
}}>
|
||||
Start typing to search documents
|
||||
{t('search.startTyping')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{
|
||||
color: 'text.secondary',
|
||||
|
|
@ -857,7 +859,7 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|||
mb: 2,
|
||||
display: 'block',
|
||||
}}>
|
||||
Popular searches:
|
||||
{t('search.popularSearches')}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
|
||||
{popularSearches.slice(0, 3).map((search, index) => (
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
Assignment as AssignmentIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Label, { type LabelData } from './Label';
|
||||
|
||||
interface LabelCreateDialogProps {
|
||||
|
|
@ -80,6 +81,7 @@ const LabelCreateDialog: React.FC<LabelCreateDialogProps> = ({
|
|||
prefilledName = '',
|
||||
editingLabel
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
|
|
@ -113,9 +115,9 @@ const LabelCreateDialog: React.FC<LabelCreateDialogProps> = ({
|
|||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
setNameError('Name is required');
|
||||
setNameError(t('labels.errors.invalidName'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -169,15 +171,15 @@ const LabelCreateDialog: React.FC<LabelCreateDialogProps> = ({
|
|||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
{editingLabel ? 'Edit Label' : 'Create New Label'}
|
||||
{editingLabel ? t('labels.create.editTitle') : t('labels.create.title')}
|
||||
</DialogTitle>
|
||||
|
||||
|
||||
<DialogContent sx={{ pt: 2 }}>
|
||||
<Grid container spacing={3}>
|
||||
{/* Name Field */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Label Name"
|
||||
label={t('labels.create.nameLabel')}
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, name: e.target.value });
|
||||
|
|
@ -195,7 +197,7 @@ const LabelCreateDialog: React.FC<LabelCreateDialogProps> = ({
|
|||
{/* Description Field */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Description (optional)"
|
||||
label={t('labels.create.descriptionLabel')}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
fullWidth
|
||||
|
|
@ -208,7 +210,7 @@ const LabelCreateDialog: React.FC<LabelCreateDialogProps> = ({
|
|||
{/* Color Selection */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Color
|
||||
{t('labels.create.colorLabel')}
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={1} mb={2}>
|
||||
{predefinedColors.map((color) => (
|
||||
|
|
@ -231,7 +233,7 @@ const LabelCreateDialog: React.FC<LabelCreateDialogProps> = ({
|
|||
))}
|
||||
</Box>
|
||||
<TextField
|
||||
label="Custom Color (hex)"
|
||||
label={t('labels.create.customColorLabel')}
|
||||
value={formData.color}
|
||||
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||||
size="small"
|
||||
|
|
@ -257,7 +259,7 @@ const LabelCreateDialog: React.FC<LabelCreateDialogProps> = ({
|
|||
{/* Icon Selection */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Icon (optional)
|
||||
{t('labels.create.iconLabel')}
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
<IconButton
|
||||
|
|
@ -269,7 +271,7 @@ const LabelCreateDialog: React.FC<LabelCreateDialogProps> = ({
|
|||
backgroundColor: !formData.icon ? 'action.selected' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">None</Typography>
|
||||
<Typography variant="caption">{t('labels.create.iconNone')}</Typography>
|
||||
</IconButton>
|
||||
{availableIcons.map((iconData) => {
|
||||
const IconComponent = iconData.icon;
|
||||
|
|
@ -295,7 +297,7 @@ const LabelCreateDialog: React.FC<LabelCreateDialogProps> = ({
|
|||
{/* Preview */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Preview
|
||||
{t('labels.create.previewLabel')}
|
||||
</Typography>
|
||||
<Paper sx={{ p: 2, backgroundColor: 'grey.50' }}>
|
||||
<Box display="flex" gap={1} flexWrap="wrap">
|
||||
|
|
@ -309,14 +311,14 @@ const LabelCreateDialog: React.FC<LabelCreateDialogProps> = ({
|
|||
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={loading}>
|
||||
Cancel
|
||||
{t('labels.create.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={loading || !formData.name.trim()}
|
||||
>
|
||||
{loading ? 'Saving...' : (editingLabel ? 'Update' : 'Create')}
|
||||
{loading ? t('labels.create.saving') : (editingLabel ? t('labels.create.update') : t('labels.create.create'))}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Button,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Edit as EditIcon } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Label, { type LabelData } from './Label';
|
||||
import LabelCreateDialog from './LabelCreateDialog';
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ const LabelSelector: React.FC<LabelSelectorProps> = ({
|
|||
showCreateButton = true,
|
||||
maxTags
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [prefilledName, setPrefilledName] = useState('');
|
||||
|
|
@ -116,7 +118,7 @@ const LabelSelector: React.FC<LabelSelectorProps> = ({
|
|||
inputValue={inputValue}
|
||||
onInputChange={(event, newInputValue) => setInputValue(newInputValue)}
|
||||
options={filteredOptions}
|
||||
groupBy={(option: LabelData) => option.is_system ? 'System Labels' : 'My Labels'}
|
||||
groupBy={(option: LabelData) => option.is_system ? t('labels.selector.systemLabels') : t('labels.selector.myLabels')}
|
||||
getOptionLabel={(option: LabelData) => option.name}
|
||||
isOptionEqualToValue={(option: LabelData, value: LabelData) => option.id === value.id}
|
||||
disabled={disabled}
|
||||
|
|
@ -130,7 +132,7 @@ const LabelSelector: React.FC<LabelSelectorProps> = ({
|
|||
endAdornment: (
|
||||
<>
|
||||
{canCreateNew && (
|
||||
<Tooltip title={`Create label "${inputValue}"`}>
|
||||
<Tooltip title={t('labels.selector.createLabel', { name: inputValue })}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleCreateNew}
|
||||
|
|
@ -214,7 +216,7 @@ const LabelSelector: React.FC<LabelSelectorProps> = ({
|
|||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<AddIcon fontSize="small" color="primary" />
|
||||
<Typography variant="body2" color="primary">
|
||||
Create "{inputValue}"
|
||||
{t('labels.selector.createLabel', { name: inputValue })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -227,7 +229,7 @@ const LabelSelector: React.FC<LabelSelectorProps> = ({
|
|||
canCreateNew ? (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No labels found
|
||||
{t('labels.selector.noLabelsFound')}
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
|
|
@ -235,13 +237,13 @@ const LabelSelector: React.FC<LabelSelectorProps> = ({
|
|||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Create "{inputValue}"
|
||||
{t('labels.selector.createLabel', { name: inputValue })}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
`No labels match "${inputValue}"`
|
||||
t('labels.selector.noLabelsMatch', { query: inputValue })
|
||||
)
|
||||
) : 'No labels available'
|
||||
) : t('labels.selector.noLabelsAvailable')
|
||||
}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
if (!inputValue) return options;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Box,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Language as LanguageIcon,
|
||||
Check as CheckIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supportedLanguages, SupportedLanguage } from '../../i18n/types';
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
color?: 'inherit' | 'default' | 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({
|
||||
size = 'medium',
|
||||
color = 'inherit'
|
||||
}) => {
|
||||
const { i18n } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleLanguageChange = (language: SupportedLanguage) => {
|
||||
i18n.changeLanguage(language);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const currentLanguage = i18n.language as SupportedLanguage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
sx={{
|
||||
color: color === 'inherit' ? 'text.secondary' : color,
|
||||
width: 44,
|
||||
height: 44,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
}}
|
||||
aria-label="change language"
|
||||
aria-controls={open ? 'language-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
>
|
||||
<LanguageIcon sx={{ fontSize: '1.25rem' }} />
|
||||
</IconButton>
|
||||
<Menu
|
||||
id="language-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onClick={handleClose}
|
||||
PaperProps={{
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
minWidth: 180,
|
||||
background: theme.palette.mode === 'light'
|
||||
? 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.90) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(30,30,30,0.95) 0%, rgba(18,18,18,0.90) 100%)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: theme.palette.mode === 'light'
|
||||
? '1px solid rgba(226,232,240,0.5)'
|
||||
: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
'&:before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 14,
|
||||
width: 10,
|
||||
height: 10,
|
||||
bgcolor: 'background.paper',
|
||||
transform: 'translateY(-50%) rotate(45deg)',
|
||||
zIndex: 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
>
|
||||
{Object.entries(supportedLanguages).map(([code, name]) => (
|
||||
<MenuItem
|
||||
key={code}
|
||||
onClick={() => handleLanguageChange(code as SupportedLanguage)}
|
||||
selected={currentLanguage === code}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mx: 1,
|
||||
my: 0.5,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.1) 0%, rgba(139,92,246,0.1) 100%)',
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.15) 0%, rgba(139,92,246,0.15) 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.2) 0%, rgba(139,92,246,0.2) 100%)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
<ListItemText
|
||||
primary={name}
|
||||
primaryTypographyProps={{
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: currentLanguage === code ? 600 : 500,
|
||||
}}
|
||||
/>
|
||||
{currentLanguage === code && (
|
||||
<ListItemIcon sx={{ minWidth: 'auto', ml: 2 }}>
|
||||
<CheckIcon
|
||||
sx={{
|
||||
fontSize: '1.1rem',
|
||||
color: 'primary.main',
|
||||
}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
)}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './LanguageSwitcher';
|
||||
|
|
@ -45,11 +45,13 @@ import { useNotifications } from '../../contexts/NotificationContext';
|
|||
import GlobalSearchBar from '../GlobalSearchBar';
|
||||
import ThemeToggle from '../ThemeToggle/ThemeToggle';
|
||||
import NotificationPanel from '../Notifications/NotificationPanel';
|
||||
import LanguageSwitcher from '../LanguageSwitcher';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const drawerWidth = 280;
|
||||
|
||||
interface NavigationItem {
|
||||
text: string;
|
||||
textKey: string;
|
||||
icon: React.ComponentType<any>;
|
||||
path: string;
|
||||
}
|
||||
|
|
@ -63,16 +65,16 @@ interface User {
|
|||
email?: string;
|
||||
}
|
||||
|
||||
const navigationItems: NavigationItem[] = [
|
||||
{ text: 'Dashboard', icon: DashboardIcon, path: '/dashboard' },
|
||||
{ text: 'Upload', icon: UploadIcon, path: '/upload' },
|
||||
{ text: 'Documents', icon: DocumentIcon, path: '/documents' },
|
||||
{ text: 'Search', icon: SearchIcon, path: '/search' },
|
||||
{ text: 'Labels', icon: LabelIcon, path: '/labels' },
|
||||
{ text: 'Sources', icon: StorageIcon, path: '/sources' },
|
||||
{ text: 'Watch Folder', icon: FolderIcon, path: '/watch' },
|
||||
{ text: 'Document Management', icon: ManageIcon, path: '/documents/management' },
|
||||
{ text: 'Ignored Files', icon: BlockIcon, path: '/ignored-files' },
|
||||
const getNavigationItems = (t: (key: string) => string): NavigationItem[] => [
|
||||
{ textKey: 'navigation.dashboard', icon: DashboardIcon, path: '/dashboard' },
|
||||
{ textKey: 'navigation.upload', icon: UploadIcon, path: '/upload' },
|
||||
{ textKey: 'navigation.documents', icon: DocumentIcon, path: '/documents' },
|
||||
{ textKey: 'navigation.search', icon: SearchIcon, path: '/search' },
|
||||
{ textKey: 'navigation.labels', icon: LabelIcon, path: '/labels' },
|
||||
{ textKey: 'navigation.sources', icon: StorageIcon, path: '/sources' },
|
||||
{ textKey: 'navigation.watchFolder', icon: FolderIcon, path: '/watch' },
|
||||
{ textKey: 'navigation.documentManagement', icon: ManageIcon, path: '/documents/management' },
|
||||
{ textKey: 'navigation.ignoredFiles', icon: BlockIcon, path: '/ignored-files' },
|
||||
];
|
||||
|
||||
const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
||||
|
|
@ -85,6 +87,9 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
const { unreadCount } = useNotifications();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const navigationItems = getNavigationItems(t);
|
||||
|
||||
const handleDrawerToggle = (): void => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
|
|
@ -182,8 +187,8 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{
|
||||
fontWeight: 800,
|
||||
<Typography variant="h6" sx={{
|
||||
fontWeight: 800,
|
||||
color: 'text.primary',
|
||||
background: theme.palette.mode === 'light'
|
||||
? 'linear-gradient(135deg, #1e293b 0%, #6366f1 100%)'
|
||||
|
|
@ -193,16 +198,16 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
WebkitTextFillColor: 'transparent',
|
||||
letterSpacing: '-0.025em',
|
||||
}}>
|
||||
Readur
|
||||
{t('common.appName')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{
|
||||
color: 'text.secondary',
|
||||
<Typography variant="caption" sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.7rem',
|
||||
}}>
|
||||
AI Document Platform
|
||||
{t('common.appTagline')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -258,7 +263,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<ListItem key={item.text} sx={{ px: 0, mb: 1 }}>
|
||||
<ListItem key={item.textKey} sx={{ px: 0, mb: 1 }}>
|
||||
<ListItemButton
|
||||
onClick={() => navigate(item.path)}
|
||||
sx={{
|
||||
|
|
@ -315,8 +320,8 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
<ListItemIcon>
|
||||
<Icon sx={{ fontSize: '1.25rem' }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.text}
|
||||
<ListItemText
|
||||
primary={t(item.textKey)}
|
||||
primaryTypographyProps={{
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
|
|
@ -429,8 +434,8 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
||||
<Typography variant="h6" noWrap component="div" sx={{
|
||||
fontWeight: 700,
|
||||
<Typography variant="h6" noWrap component="div" sx={{
|
||||
fontWeight: 700,
|
||||
mr: 1,
|
||||
fontSize: '1.1rem',
|
||||
background: theme.palette.mode === 'light'
|
||||
|
|
@ -441,7 +446,9 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
WebkitTextFillColor: 'transparent',
|
||||
letterSpacing: '-0.025em',
|
||||
}}>
|
||||
{navigationItems.find(item => item.path === location.pathname)?.text || 'Dashboard'}
|
||||
{navigationItems.find(item => item.path === location.pathname)?.textKey
|
||||
? t(navigationItems.find(item => item.path === location.pathname)!.textKey)
|
||||
: t('navigation.dashboard')}
|
||||
</Typography>
|
||||
|
||||
{/* Global Search Bar */}
|
||||
|
|
@ -488,8 +495,29 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
{/* Language Switcher */}
|
||||
<Box sx={{
|
||||
mr: 2,
|
||||
background: theme.palette.mode === 'light'
|
||||
? 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(50,50,50,0.8) 0%, rgba(30,30,30,0.6) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: theme.palette.mode === 'light'
|
||||
? '1px solid rgba(255,255,255,0.3)'
|
||||
: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 2.5,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.1) 0%, rgba(139,92,246,0.1) 100%)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 24px rgba(99,102,241,0.15)',
|
||||
},
|
||||
}}>
|
||||
<LanguageSwitcher size="medium" color="inherit" />
|
||||
</Box>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<Box sx={{
|
||||
<Box sx={{
|
||||
mr: 2,
|
||||
background: theme.palette.mode === 'light'
|
||||
? 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%)'
|
||||
|
|
@ -570,21 +598,21 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
>
|
||||
<MenuItem onClick={() => navigate('/profile')}>
|
||||
<Avatar /> Profile
|
||||
<Avatar /> {t('auth.profile')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => navigate('/settings')}>
|
||||
<SettingsIcon sx={{ mr: 2 }} /> Settings
|
||||
<SettingsIcon sx={{ mr: 2 }} /> {t('settings.title')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => navigate('/debug')}>
|
||||
<BugReportIcon sx={{ mr: 2 }} /> Debug
|
||||
<BugReportIcon sx={{ mr: 2 }} /> {t('settings.debug')}
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => window.open('/swagger-ui', '_blank')}>
|
||||
<ApiIcon sx={{ mr: 2 }} /> API Documentation
|
||||
<ApiIcon sx={{ mr: 2 }} /> {t('settings.apiDocumentation')}
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<LogoutIcon sx={{ mr: 2 }} /> Logout
|
||||
<LogoutIcon sx={{ mr: 2 }} /> {t('auth.logout')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Toolbar>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
Delete as DeleteIcon,
|
||||
DoneAll as DoneAllIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNotifications } from '../../contexts/NotificationContext';
|
||||
import { NotificationType } from '../../types/notification';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
|
@ -34,6 +35,7 @@ interface NotificationPanelProps {
|
|||
|
||||
const NotificationPanel: React.FC<NotificationPanelProps> = ({ anchorEl, onClose }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { notifications, unreadCount, markAsRead, markAllAsRead, clearNotification, clearAll } = useNotifications();
|
||||
|
||||
const getIcon = (type: NotificationType) => {
|
||||
|
|
@ -88,7 +90,7 @@ const NotificationPanel: React.FC<NotificationPanelProps> = ({ anchorEl, onClose
|
|||
>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
Notifications
|
||||
{t('notifications.title')}
|
||||
</Typography>
|
||||
{unreadCount > 0 && (
|
||||
<Chip
|
||||
|
|
@ -108,7 +110,7 @@ const NotificationPanel: React.FC<NotificationPanelProps> = ({ anchorEl, onClose
|
|||
<IconButton
|
||||
size="small"
|
||||
onClick={markAllAsRead}
|
||||
title="Mark all as read"
|
||||
title={t('notifications.markAllAsRead')}
|
||||
sx={{ opacity: 0.7, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<DoneAllIcon fontSize="small" />
|
||||
|
|
@ -116,7 +118,7 @@ const NotificationPanel: React.FC<NotificationPanelProps> = ({ anchorEl, onClose
|
|||
<IconButton
|
||||
size="small"
|
||||
onClick={clearAll}
|
||||
title="Clear all"
|
||||
title={t('notifications.clearAll')}
|
||||
sx={{ opacity: 0.7, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
|
|
@ -139,7 +141,7 @@ const NotificationPanel: React.FC<NotificationPanelProps> = ({ anchorEl, onClose
|
|||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">No notifications</Typography>
|
||||
<Typography variant="body2">{t('notifications.noNotifications')}</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List sx={{ p: 0 }}>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
SelectChangeEvent,
|
||||
} from '@mui/material';
|
||||
import { Language as LanguageIcon } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ocrService, LanguageInfo } from '../../services/api';
|
||||
|
||||
interface OcrLanguageSelectorProps {
|
||||
|
|
@ -37,6 +38,7 @@ const OcrLanguageSelector: React.FC<OcrLanguageSelectorProps> = ({
|
|||
required = false,
|
||||
helperText,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [languages, setLanguages] = useState<LanguageInfo[]>([]);
|
||||
const [currentUserLanguage, setCurrentUserLanguage] = useState<string>('eng');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
|
@ -88,7 +90,7 @@ const OcrLanguageSelector: React.FC<OcrLanguageSelectorProps> = ({
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading languages...
|
||||
{t('ocr.languageSelector.loading')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</FormControl>
|
||||
|
|
@ -98,16 +100,16 @@ const OcrLanguageSelector: React.FC<OcrLanguageSelectorProps> = ({
|
|||
if (error) {
|
||||
return (
|
||||
<Box>
|
||||
<Alert
|
||||
severity="warning"
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{ mb: 1 }}
|
||||
action={
|
||||
<Typography
|
||||
variant="button"
|
||||
<Typography
|
||||
variant="button"
|
||||
onClick={fetchLanguages}
|
||||
sx={{ cursor: 'pointer', textDecoration: 'underline' }}
|
||||
>
|
||||
Retry
|
||||
{t('ocr.languageSelector.retry')}
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
|
|
@ -116,7 +118,7 @@ const OcrLanguageSelector: React.FC<OcrLanguageSelectorProps> = ({
|
|||
<FormControl fullWidth={fullWidth} size={size} disabled>
|
||||
<InputLabel>{label}</InputLabel>
|
||||
<Select value="eng">
|
||||
<MenuItem value="eng">English (Fallback)</MenuItem>
|
||||
<MenuItem value="eng">{t('ocr.languageSelector.fallback')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
|
@ -143,10 +145,10 @@ const OcrLanguageSelector: React.FC<OcrLanguageSelectorProps> = ({
|
|||
{language.code}
|
||||
</Typography>
|
||||
{showCurrentIndicator && language.code === currentUserLanguage && (
|
||||
<Chip
|
||||
label="Current"
|
||||
size="small"
|
||||
color="primary"
|
||||
<Chip
|
||||
label={t('ocr.languageSelector.current')}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.7rem', height: '20px' }}
|
||||
/>
|
||||
|
|
@ -162,12 +164,16 @@ const OcrLanguageSelector: React.FC<OcrLanguageSelectorProps> = ({
|
|||
</Typography>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
|
||||
|
||||
{showCurrentIndicator && languages.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
{languages.length} language{languages.length !== 1 ? 's' : ''} available
|
||||
{t('ocr.languageSelector.languagesAvailable', {
|
||||
count: languages.length,
|
||||
plural: languages.length !== 1 ? 's' : ''
|
||||
})}
|
||||
{value && value !== currentUserLanguage && (
|
||||
<span> • Selecting "{getLanguageDisplay(value)}" will update your default language</span>
|
||||
<span> • {t('ocr.languageSelector.selectingWillUpdate', { language: getLanguageDisplay(value) })}</span>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
function Register() {
|
||||
const { t } = useTranslation()
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
|
@ -18,7 +20,7 @@ function Register() {
|
|||
try {
|
||||
await register(username, email, password)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to register')
|
||||
setError(err.response?.data?.message || t('register.errors.failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -29,7 +31,7 @@ function Register() {
|
|||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Create your Readur account
|
||||
{t('register.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
|
|
@ -40,7 +42,7 @@ function Register() {
|
|||
)}
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
Username
|
||||
{t('register.fields.username')}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
|
|
@ -48,14 +50,14 @@ function Register() {
|
|||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Username"
|
||||
placeholder={t('register.placeholders.username')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email
|
||||
{t('register.fields.email')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
|
|
@ -63,14 +65,14 @@ function Register() {
|
|||
type="email"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Email"
|
||||
placeholder={t('register.placeholders.email')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
{t('register.fields.password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
|
|
@ -78,7 +80,7 @@ function Register() {
|
|||
type="password"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Password"
|
||||
placeholder={t('register.placeholders.password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
|
|
@ -89,12 +91,12 @@ function Register() {
|
|||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Sign up'}
|
||||
{loading ? t('register.actions.creating') : t('register.actions.signup')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Link to="/login" className="text-blue-600 hover:text-blue-500">
|
||||
Already have an account? Sign in
|
||||
{t('register.links.signin')}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
} from '@mui/icons-material';
|
||||
import { useDropzone, FileRejection, DropzoneOptions } from 'react-dropzone';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api, ErrorHelper, ErrorCodes } from '../../services/api';
|
||||
import { useNotifications } from '../../contexts/NotificationContext';
|
||||
import LabelSelector from '../Labels/LabelSelector';
|
||||
|
|
@ -65,6 +66,7 @@ type FileStatus = 'pending' | 'uploading' | 'success' | 'error' | 'timeout' | 'c
|
|||
const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { addBatchNotification } = useNotifications();
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
|
|
@ -97,13 +99,13 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
|||
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
|
||||
|
||||
// Handle specific label fetch errors
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
|
||||
setError('Your session has expired. Please refresh the page and log in again.');
|
||||
setError(t('upload.errors.sessionExpired'));
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
|
||||
setError('You do not have permission to access labels.');
|
||||
setError(t('upload.errors.labelPermissionDenied'));
|
||||
} else if (errorInfo.category === 'network') {
|
||||
setError('Network error loading labels. Please check your connection.');
|
||||
setError(t('upload.errors.labelNetworkError'));
|
||||
} else {
|
||||
// Don't show error for label loading failures as it's not critical
|
||||
console.warn('Label loading failed:', errorInfo.message);
|
||||
|
|
@ -126,15 +128,15 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
|||
|
||||
// Handle specific label creation errors
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_DUPLICATE_NAME)) {
|
||||
throw new Error('A label with this name already exists. Please choose a different name.');
|
||||
throw new Error(t('labels.errors.duplicateName'));
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_INVALID_NAME)) {
|
||||
throw new Error('Label name contains invalid characters. Please use only letters, numbers, and basic punctuation.');
|
||||
throw new Error(t('labels.errors.invalidName'));
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_INVALID_COLOR)) {
|
||||
throw new Error('Invalid color format. Please use a valid hex color like #0969da.');
|
||||
throw new Error(t('labels.errors.invalidColor'));
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.LABEL_MAX_LABELS_REACHED)) {
|
||||
throw new Error('Maximum number of labels reached. Please delete some labels before creating new ones.');
|
||||
throw new Error(t('labels.errors.maxLabelsReached'));
|
||||
} else {
|
||||
throw new Error(errorInfo.message || 'Failed to create label');
|
||||
throw new Error(errorInfo.message || t('labels.errors.loadFailed'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -261,22 +263,22 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
|||
|
||||
// Handle specific document upload errors
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_TOO_LARGE)) {
|
||||
errorMessage = 'File is too large. Maximum size is 50MB.';
|
||||
errorMessage = t('upload.errors.fileTooLarge');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_INVALID_FORMAT)) {
|
||||
errorMessage = 'Unsupported file format. Please use PDF, images, text, or Word documents.';
|
||||
errorMessage = t('upload.errors.unsupportedFormat');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_OCR_FAILED)) {
|
||||
errorMessage = 'Failed to process document. Please try again or contact support.';
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
errorMessage = t('upload.errors.processingFailed');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
|
||||
errorMessage = 'Session expired. Please refresh and log in again.';
|
||||
errorMessage = t('upload.errors.sessionExpired');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
|
||||
errorMessage = 'You do not have permission to upload documents.';
|
||||
errorMessage = t('upload.errors.permissionDenied');
|
||||
} else if (errorInfo.category === 'network') {
|
||||
errorMessage = 'Network error. Please check your connection and try again.';
|
||||
errorMessage = t('upload.errors.networkError');
|
||||
} else if (errorInfo.category === 'server') {
|
||||
errorMessage = 'Server error. Please try again later.';
|
||||
errorMessage = t('upload.errors.serverError');
|
||||
} else {
|
||||
errorMessage = errorInfo.message || 'Upload failed';
|
||||
errorMessage = errorInfo.message || t('common.status.failed');
|
||||
}
|
||||
|
||||
setFiles(prev => prev.map(f =>
|
||||
|
|
@ -471,33 +473,33 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
|||
/>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
{isDragActive ? 'Drop files here' : 'Drag & drop files here'}
|
||||
{isDragActive ? t('upload.dropzone.dropHere') : t('upload.dropzone.dragDrop')}
|
||||
</Typography>
|
||||
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
or click to browse your computer
|
||||
{t('upload.dropzone.browse')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{
|
||||
mb: 2,
|
||||
borderRadius: 2,
|
||||
px: 3,
|
||||
}}
|
||||
>
|
||||
Choose Files
|
||||
{t('upload.dropzone.chooseFiles')}
|
||||
</Button>
|
||||
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip label="PDF" size="small" variant="outlined" />
|
||||
<Chip label="Images" size="small" variant="outlined" />
|
||||
<Chip label="Text" size="small" variant="outlined" />
|
||||
<Chip label="Word" size="small" variant="outlined" />
|
||||
<Chip label={t('upload.dropzone.fileTypes.pdf')} size="small" variant="outlined" />
|
||||
<Chip label={t('upload.dropzone.fileTypes.images')} size="small" variant="outlined" />
|
||||
<Chip label={t('upload.dropzone.fileTypes.text')} size="small" variant="outlined" />
|
||||
<Chip label={t('upload.dropzone.fileTypes.word')} size="small" variant="outlined" />
|
||||
</Box>
|
||||
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 2 }}>
|
||||
Maximum file size: 50MB per file
|
||||
{t('upload.dropzone.maxFileSize')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
|
@ -514,10 +516,10 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
|||
<Card elevation={0} sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
🌐 OCR Language Settings
|
||||
{t('upload.languageSettings.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Select languages for optimal OCR text recognition
|
||||
{t('upload.languageSettings.description')}
|
||||
</Typography>
|
||||
<Box sx={{ '& > div': { width: '100%' } }}>
|
||||
<LanguageSelector
|
||||
|
|
@ -534,23 +536,23 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
|||
<Card elevation={0} sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
📋 Label Assignment
|
||||
{t('upload.labelAssignment.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Select labels to automatically assign to all uploaded documents
|
||||
{t('upload.labelAssignment.description')}
|
||||
</Typography>
|
||||
<LabelSelector
|
||||
selectedLabels={selectedLabels}
|
||||
availableLabels={availableLabels}
|
||||
onLabelsChange={setSelectedLabels}
|
||||
onCreateLabel={handleCreateLabel}
|
||||
placeholder="Choose labels for your documents..."
|
||||
placeholder={t('upload.labelAssignment.placeholder')}
|
||||
size="medium"
|
||||
disabled={labelsLoading}
|
||||
/>
|
||||
{selectedLabels.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
These labels will be applied to all uploaded documents
|
||||
{t('upload.labelAssignment.helperText')}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -562,7 +564,7 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
|||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Files ({files.length})
|
||||
{t('upload.fileList.title', { count: files.length })}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
|
|
@ -570,7 +572,7 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
|||
onClick={clearCompleted}
|
||||
disabled={!files.some(f => f.status === 'success')}
|
||||
>
|
||||
Clear Completed
|
||||
{t('upload.fileList.clearCompleted')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
|
|
@ -580,10 +582,10 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
|||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{uploading ? (
|
||||
uploadProgress.total > 0 ?
|
||||
`Uploading... (${uploadProgress.completed}/${uploadProgress.total})` :
|
||||
'Uploading...'
|
||||
) : 'Upload All'}
|
||||
uploadProgress.total > 0 ?
|
||||
t('upload.fileList.uploading', { completed: uploadProgress.completed, total: uploadProgress.total }) :
|
||||
t('upload.fileList.uploadingSimple')
|
||||
) : t('upload.fileList.uploadAll')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import Backend from 'i18next-http-backend';
|
||||
|
||||
i18n
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
debug: import.meta.env.DEV,
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
|
||||
backend: {
|
||||
loadPath: '/locales/{{lng}}/translation.json',
|
||||
},
|
||||
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
caches: ['localStorage'],
|
||||
lookupLocalStorage: 'i18nextLng',
|
||||
},
|
||||
|
||||
react: {
|
||||
useSuspense: true,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export const supportedLanguages = {
|
||||
en: 'English',
|
||||
es: 'Español',
|
||||
} as const;
|
||||
|
||||
export type SupportedLanguage = keyof typeof supportedLanguages;
|
||||
|
||||
export const defaultLanguage: SupportedLanguage = 'en';
|
||||
|
|
@ -1,16 +1,19 @@
|
|||
import React from 'react'
|
||||
import React, { Suspense } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import './i18n/config'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</Suspense>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
|
@ -43,6 +43,7 @@ import {
|
|||
Refresh as RefreshIcon,
|
||||
Visibility as PreviewIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../services/api';
|
||||
|
||||
interface DebugStep {
|
||||
|
|
@ -77,12 +78,13 @@ interface DebugInfo {
|
|||
}
|
||||
|
||||
const DebugPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<number>(0);
|
||||
const [documentId, setDocumentId] = useState<string>('');
|
||||
const [debugInfo, setDebugInfo] = useState<DebugInfo | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
|
||||
// Upload functionality
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
|
|
@ -126,7 +128,7 @@ const DebugPage: React.FC = () => {
|
|||
const fetchDebugInfo = useCallback(async (docId?: string, retryCount = 0) => {
|
||||
const targetDocId = docId || documentId;
|
||||
if (!targetDocId.trim()) {
|
||||
setError('Please enter a document ID');
|
||||
setError(t('debug.errors.enterDocumentId'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -134,14 +136,14 @@ const DebugPage: React.FC = () => {
|
|||
if (retryCount === 0) {
|
||||
setError(''); // Only clear error on first attempt
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await api.get(`/documents/${targetDocId}/debug`);
|
||||
setDebugInfo(response.data);
|
||||
setError(''); // Clear any previous errors
|
||||
} catch (err: any) {
|
||||
console.error('Debug fetch error:', err);
|
||||
|
||||
|
||||
// If it's a 404 and we haven't retried much, try again after a short delay
|
||||
if (err.response?.status === 404 && retryCount < 3) {
|
||||
console.log(`Document not found, retrying in ${(retryCount + 1) * 1000}ms... (attempt ${retryCount + 1})`);
|
||||
|
|
@ -150,10 +152,10 @@ const DebugPage: React.FC = () => {
|
|||
}, (retryCount + 1) * 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = err.response?.status === 404
|
||||
? `Document ${targetDocId} not found. It may still be processing or may have been moved to failed documents.`
|
||||
: err.response?.data?.message || `Failed to fetch debug information: ${err.message}`;
|
||||
|
||||
const errorMessage = err.response?.status === 404
|
||||
? t('debug.errors.documentNotFound', { documentId: targetDocId })
|
||||
: err.response?.data?.message || t('debug.errors.fetchFailed', { message: err.message });
|
||||
setError(errorMessage);
|
||||
setDebugInfo(null);
|
||||
} finally {
|
||||
|
|
@ -161,7 +163,7 @@ const DebugPage: React.FC = () => {
|
|||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [documentId]);
|
||||
}, [documentId, t]);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
|
|
@ -173,14 +175,14 @@ const DebugPage: React.FC = () => {
|
|||
|
||||
const uploadDocument = useCallback(async () => {
|
||||
if (!selectedFile) {
|
||||
setError('Please select a file to upload');
|
||||
setError(t('debug.upload.selectFile'));
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
setError('');
|
||||
setProcessingStatus('Uploading file...');
|
||||
setProcessingStatus(t('debug.upload.uploading'));
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
|
@ -191,7 +193,7 @@ const DebugPage: React.FC = () => {
|
|||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const progress = progressEvent.total
|
||||
const progress = progressEvent.total
|
||||
? Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
: 0;
|
||||
setUploadProgress(progress);
|
||||
|
|
@ -201,18 +203,18 @@ const DebugPage: React.FC = () => {
|
|||
const uploadedDoc = response.data;
|
||||
setUploadedDocumentId(uploadedDoc.id);
|
||||
setDocumentId(uploadedDoc.id);
|
||||
setProcessingStatus('Document uploaded successfully. Starting OCR processing...');
|
||||
|
||||
setProcessingStatus(t('debug.upload.uploadedStartingOcr'));
|
||||
|
||||
// Start monitoring the processing
|
||||
startProcessingMonitor(uploadedDoc.id);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to upload document');
|
||||
setProcessingStatus('Upload failed');
|
||||
setError(err.response?.data?.message || t('debug.upload.uploadFailed'));
|
||||
setProcessingStatus(t('debug.upload.uploadFailedStatus'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
}, [selectedFile]);
|
||||
}, [selectedFile, t]);
|
||||
|
||||
const startProcessingMonitor = useCallback((docId: string) => {
|
||||
// Clear any existing interval
|
||||
|
|
@ -224,23 +226,23 @@ const DebugPage: React.FC = () => {
|
|||
try {
|
||||
const response = await api.get(`/documents/${docId}`);
|
||||
const doc = response.data;
|
||||
|
||||
|
||||
if (doc.ocr_status === 'completed' || doc.ocr_status === 'failed') {
|
||||
setProcessingStatus(`Processing ${doc.ocr_status}!`);
|
||||
setProcessingStatus(t('debug.monitoring.processingComplete', { status: doc.ocr_status }));
|
||||
clearInterval(interval);
|
||||
setMonitoringInterval(null);
|
||||
|
||||
|
||||
// Auto-fetch debug info when processing is complete OR failed (but don't switch tabs)
|
||||
setTimeout(() => {
|
||||
fetchDebugInfo(docId);
|
||||
// Don't auto-switch tabs - let user decide when to view debug info
|
||||
}, 2000); // Give it a bit more time to ensure document is saved
|
||||
} else if (doc.ocr_status === 'processing') {
|
||||
setProcessingStatus('OCR processing in progress...');
|
||||
setProcessingStatus(t('debug.monitoring.ocrInProgress'));
|
||||
} else if (doc.ocr_status === 'pending') {
|
||||
setProcessingStatus('Document queued for OCR processing...');
|
||||
setProcessingStatus(t('debug.monitoring.queuedForOcr'));
|
||||
} else {
|
||||
setProcessingStatus('Checking processing status...');
|
||||
setProcessingStatus(t('debug.monitoring.checkingStatus'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error monitoring processing:', err);
|
||||
|
|
@ -248,14 +250,14 @@ const DebugPage: React.FC = () => {
|
|||
}, 2000); // Check every 2 seconds
|
||||
|
||||
setMonitoringInterval(interval);
|
||||
|
||||
|
||||
// Auto-clear monitoring after 5 minutes
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
setMonitoringInterval(null);
|
||||
setProcessingStatus('Monitoring stopped (timeout)');
|
||||
setProcessingStatus(t('debug.monitoring.monitoringTimeout'));
|
||||
}, 300000);
|
||||
}, [monitoringInterval, fetchDebugInfo]);
|
||||
}, [monitoringInterval, fetchDebugInfo, t]);
|
||||
|
||||
// Cleanup interval on unmount
|
||||
useEffect(() => {
|
||||
|
|
@ -282,31 +284,31 @@ const DebugPage: React.FC = () => {
|
|||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>File Information</Typography>
|
||||
<Typography><strong>Filename:</strong> {details.filename}</Typography>
|
||||
<Typography><strong>Original:</strong> {details.original_filename}</Typography>
|
||||
<Typography><strong>Size:</strong> {(details.file_size / 1024 / 1024).toFixed(2)} MB</Typography>
|
||||
<Typography><strong>MIME Type:</strong> {details.mime_type}</Typography>
|
||||
<Typography><strong>File Exists:</strong> <Chip
|
||||
label={details.file_exists ? 'Yes' : 'No'}
|
||||
color={details.file_exists ? 'success' : 'error'}
|
||||
size="small"
|
||||
<Typography variant="h6" gutterBottom>{t('debug.steps.fileInformation.title')}</Typography>
|
||||
<Typography><strong>{t('debug.steps.fileInformation.filename')}</strong> {details.filename}</Typography>
|
||||
<Typography><strong>{t('debug.steps.fileInformation.original')}</strong> {details.original_filename}</Typography>
|
||||
<Typography><strong>{t('debug.steps.fileInformation.size')}</strong> {(details.file_size / 1024 / 1024).toFixed(2)} MB</Typography>
|
||||
<Typography><strong>{t('debug.steps.fileInformation.mimeType')}</strong> {details.mime_type}</Typography>
|
||||
<Typography><strong>{t('debug.steps.fileInformation.fileExists')}</strong> <Chip
|
||||
label={details.file_exists ? t('debug.steps.fileInformation.yes') : t('debug.steps.fileInformation.no')}
|
||||
color={details.file_exists ? 'success' : 'error'}
|
||||
size="small"
|
||||
/></Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>File Metadata</Typography>
|
||||
<Typography variant="h6" gutterBottom>{t('debug.steps.fileMetadata.title')}</Typography>
|
||||
{details.file_metadata ? (
|
||||
<>
|
||||
<Typography><strong>Actual Size:</strong> {(details.file_metadata.size / 1024 / 1024).toFixed(2)} MB</Typography>
|
||||
<Typography><strong>Is File:</strong> {details.file_metadata.is_file ? 'Yes' : 'No'}</Typography>
|
||||
<Typography><strong>Modified:</strong> {details.file_metadata.modified ? new Date(details.file_metadata.modified.secs_since_epoch * 1000).toLocaleString() : 'Unknown'}</Typography>
|
||||
<Typography><strong>{t('debug.steps.fileMetadata.actualSize')}</strong> {(details.file_metadata.size / 1024 / 1024).toFixed(2)} MB</Typography>
|
||||
<Typography><strong>{t('debug.steps.fileMetadata.isFile')}</strong> {details.file_metadata.is_file ? t('debug.steps.fileInformation.yes') : t('debug.steps.fileInformation.no')}</Typography>
|
||||
<Typography><strong>{t('debug.steps.fileMetadata.modified')}</strong> {details.file_metadata.modified ? new Date(details.file_metadata.modified.secs_since_epoch * 1000).toLocaleString() : t('debug.steps.fileMetadata.unknown')}</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography color="text.secondary">File metadata not available</Typography>
|
||||
<Typography color="text.secondary">{t('debug.steps.fileMetadata.notAvailable')}</Typography>
|
||||
)}
|
||||
<Typography><strong>Created:</strong> {new Date(details.created_at).toLocaleString()}</Typography>
|
||||
<Typography><strong>{t('debug.steps.fileMetadata.created')}</strong> {new Date(details.created_at).toLocaleString()}</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
@ -541,12 +543,12 @@ const DebugPage: React.FC = () => {
|
|||
<Card sx={{ mb: 4 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Upload Document for Debug Analysis
|
||||
{t('debug.upload.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Upload a PDF or image file to analyze the processing pipeline in real-time.
|
||||
{t('debug.upload.description')}
|
||||
</Typography>
|
||||
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<input
|
||||
accept=".pdf,.png,.jpg,.jpeg,.tiff,.bmp,.txt"
|
||||
|
|
@ -563,18 +565,18 @@ const DebugPage: React.FC = () => {
|
|||
disabled={uploading}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Select File
|
||||
{t('debug.upload.selectFileButton')}
|
||||
</Button>
|
||||
</label>
|
||||
|
||||
|
||||
{selectedFile && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Selected:</strong> {selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
<strong>{t('debug.upload.selected')}</strong> {selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
{selectedFile && (
|
||||
<Button
|
||||
variant="contained"
|
||||
|
|
@ -583,15 +585,15 @@ const DebugPage: React.FC = () => {
|
|||
startIcon={uploading ? <CircularProgress size={20} /> : <UploadIcon />}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload & Debug'}
|
||||
{uploading ? t('debug.upload.uploadingButton') : t('debug.upload.uploadDebugButton')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
{uploading && uploadProgress > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Upload Progress: {uploadProgress}%
|
||||
{t('debug.upload.uploadProgress', { percent: uploadProgress })}
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||
</Box>
|
||||
|
|
@ -615,7 +617,7 @@ const DebugPage: React.FC = () => {
|
|||
{uploadedDocumentId && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Document ID:</strong> {uploadedDocumentId}
|
||||
<strong>{t('debug.upload.documentId')}</strong> {uploadedDocumentId}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button
|
||||
|
|
@ -629,7 +631,7 @@ const DebugPage: React.FC = () => {
|
|||
sx={{ mr: 1 }}
|
||||
color={processingStatus.includes('failed') ? 'error' : 'primary'}
|
||||
>
|
||||
{processingStatus.includes('failed') ? 'Show Debug Details' : 'Debug Analysis'}
|
||||
{processingStatus.includes('failed') ? t('debug.actions.showDebugDetails') : t('debug.actions.debugAnalysis')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
|
@ -638,7 +640,7 @@ const DebugPage: React.FC = () => {
|
|||
startIcon={<RefreshIcon />}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Refresh Status
|
||||
{t('debug.actions.refreshStatus')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
|
@ -646,7 +648,7 @@ const DebugPage: React.FC = () => {
|
|||
onClick={() => window.open(`/api/documents/${uploadedDocumentId}/view`, '_blank')}
|
||||
startIcon={<PreviewIcon />}
|
||||
>
|
||||
View Document
|
||||
{t('debug.actions.viewDocument')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -654,8 +656,8 @@ const DebugPage: React.FC = () => {
|
|||
|
||||
{selectedFile && selectedFile.type.startsWith('image/') && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Preview</Typography>
|
||||
<Box
|
||||
<Typography variant="h6" gutterBottom>{t('debug.preview')}</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
src={URL.createObjectURL(selectedFile)}
|
||||
alt="Document preview"
|
||||
|
|
@ -680,18 +682,18 @@ const DebugPage: React.FC = () => {
|
|||
<Card sx={{ mb: 4 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Debug Existing Document
|
||||
{t('debug.search.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Enter a document ID to analyze the processing pipeline for an existing document.
|
||||
{t('debug.search.description')}
|
||||
</Typography>
|
||||
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="Document ID"
|
||||
label={t('debug.search.documentIdLabel')}
|
||||
value={documentId}
|
||||
onChange={(e) => setDocumentId(e.target.value)}
|
||||
placeholder="e.g., 123e4567-e89b-12d3-a456-426614174000"
|
||||
placeholder={t('debug.search.documentIdPlaceholder')}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
|
|
@ -701,10 +703,10 @@ const DebugPage: React.FC = () => {
|
|||
disabled={loading || !documentId.trim()}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <SearchIcon />}
|
||||
>
|
||||
Debug
|
||||
{t('debug.search.debugButton')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
|
|
@ -720,36 +722,36 @@ const DebugPage: React.FC = () => {
|
|||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
<BugReportIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Document Processing Debug
|
||||
{t('debug.title')}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Upload documents or analyze existing ones to troubleshoot OCR processing issues.
|
||||
{t('debug.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Card sx={{ mb: 4 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={activeTab} onChange={(_, newValue) => setActiveTab(newValue)}>
|
||||
<Tab
|
||||
label="Upload & Debug"
|
||||
icon={<UploadIcon />}
|
||||
<Tab
|
||||
label={t('debug.tabs.uploadAndDebug')}
|
||||
icon={<UploadIcon />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab
|
||||
label="Search Existing"
|
||||
icon={<SearchIcon />}
|
||||
<Tab
|
||||
label={t('debug.tabs.searchExisting')}
|
||||
icon={<SearchIcon />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
{debugInfo && (
|
||||
<Tab
|
||||
label="Debug Results"
|
||||
icon={<PreviewIcon />}
|
||||
<Tab
|
||||
label={t('debug.tabs.debugResults')}
|
||||
icon={<PreviewIcon />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
)}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
|
||||
<CardContent>
|
||||
{activeTab === 0 && renderUploadTab()}
|
||||
{activeTab === 1 && renderSearchTab()}
|
||||
|
|
@ -758,7 +760,7 @@ const DebugPage: React.FC = () => {
|
|||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 4 }}>
|
||||
<Typography variant="h6">Debug Error</Typography>
|
||||
<Typography variant="h6">{t('debug.errors.debugError')}</Typography>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -768,15 +770,15 @@ const DebugPage: React.FC = () => {
|
|||
<Card sx={{ mb: 4 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Document: {debugInfo.filename}
|
||||
{t('debug.document.title', { filename: debugInfo.filename })}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2 }}>
|
||||
<Chip
|
||||
label={`Status: ${debugInfo.overall_status}`}
|
||||
<Chip
|
||||
label={t('debug.document.status', { status: debugInfo.overall_status })}
|
||||
color={getStatusColor(debugInfo.overall_status, debugInfo.overall_status === 'success')}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Debug run at: {new Date(debugInfo.debug_timestamp).toLocaleString()}
|
||||
{t('debug.document.debugRunAt', { timestamp: new Date(debugInfo.debug_timestamp).toLocaleString() })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
|
@ -785,7 +787,7 @@ const DebugPage: React.FC = () => {
|
|||
<Card sx={{ mb: 4 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Processing Pipeline
|
||||
{t('debug.pipeline.title')}
|
||||
</Typography>
|
||||
<Stepper orientation="vertical">
|
||||
{(debugInfo.pipeline_steps || []).map((step) => (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
|
|
@ -65,6 +66,7 @@ import { useTheme as useMuiTheme } from '@mui/material/styles';
|
|||
import api from '../services/api';
|
||||
|
||||
const DocumentDetailsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { mode, modernTokens, glassEffect } = useTheme();
|
||||
|
|
@ -134,7 +136,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
} catch (error) {
|
||||
console.error('Failed to delete document:', error);
|
||||
// Show error message to user
|
||||
alert('Failed to delete document. Please try again.');
|
||||
alert(t('common.status.error'));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteConfirmOpen(false);
|
||||
|
|
@ -170,7 +172,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
|
||||
const fetchDocumentDetails = async (): Promise<void> => {
|
||||
if (!id) {
|
||||
setError('No document ID provided');
|
||||
setError(t('documentDetails.errors.notFound'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -178,11 +180,11 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
const response = await documentService.getById(id);
|
||||
setDocument(response.data);
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.message || 'Failed to load document details';
|
||||
const errorMessage = err.message || t('common.status.error');
|
||||
setError(errorMessage);
|
||||
console.error('Failed to fetch document details:', err);
|
||||
} finally {
|
||||
|
|
@ -215,10 +217,10 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
setOcrLoading(true);
|
||||
const response = await documentService.getOcrText(document.id);
|
||||
setOcrData(response.data);
|
||||
setOcrText(response.data.ocr_text || 'No OCR text available');
|
||||
setOcrText(response.data.ocr_text || t('documentDetails.ocr.noText'));
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch OCR text:', err);
|
||||
setOcrText('Failed to load OCR text. Please try again.');
|
||||
setOcrText(t('documentDetails.ocr.loadFailed'));
|
||||
} finally {
|
||||
setOcrLoading(false);
|
||||
}
|
||||
|
|
@ -242,7 +244,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
setShowProcessedImageDialog(true);
|
||||
} catch (err: any) {
|
||||
console.log('Processed image not available:', err);
|
||||
alert('No processed image available for this document. This feature requires "Save Processed Images" to be enabled in OCR settings.');
|
||||
alert(t('documentDetails.dialogs.processedImage.noImage'));
|
||||
} finally {
|
||||
setProcessedImageLoading(false);
|
||||
}
|
||||
|
|
@ -357,10 +359,10 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
onClick={() => navigate('/documents')}
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
Back to Documents
|
||||
{t('documentDetails.actions.backToDocuments')}
|
||||
</Button>
|
||||
<Alert severity="error">
|
||||
{error || 'Document not found'}
|
||||
{error || t('documentDetails.errors.notFound')}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -380,7 +382,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
<Button
|
||||
startIcon={<BackIcon />}
|
||||
onClick={() => navigate('/documents')}
|
||||
sx={{
|
||||
sx={{
|
||||
mb: 3,
|
||||
color: theme.palette.text.secondary,
|
||||
'&:hover': {
|
||||
|
|
@ -388,7 +390,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
Back to Documents
|
||||
{t('documentDetails.actions.backToDocuments')}
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
|
|
@ -403,12 +405,12 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{document?.original_filename || 'Document Details'}
|
||||
{document?.original_filename || t('navigation.documents')}
|
||||
</Typography>
|
||||
|
||||
|
||||
{/* Floating Action Menu */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title="Download">
|
||||
<Tooltip title={t('documentDetails.actions.download')}>
|
||||
<IconButton
|
||||
onClick={handleDownload}
|
||||
sx={{
|
||||
|
|
@ -424,8 +426,8 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="View Document">
|
||||
|
||||
<Tooltip title={t('documentDetails.actions.viewDocument')}>
|
||||
<IconButton
|
||||
onClick={handleViewDocument}
|
||||
sx={{
|
||||
|
|
@ -441,9 +443,9 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
<ViewIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
{document?.has_ocr_text && (
|
||||
<Tooltip title="View OCR Text">
|
||||
<Tooltip title={t('documentDetails.actions.viewOcrText')}>
|
||||
<IconButton
|
||||
onClick={handleViewOcr}
|
||||
sx={{
|
||||
|
|
@ -460,8 +462,8 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title="Delete Document">
|
||||
|
||||
<Tooltip title={t('documentDetails.actions.deleteDocument')}>
|
||||
<IconButton
|
||||
onClick={handleDeleteClick}
|
||||
disabled={deleting}
|
||||
|
|
@ -483,9 +485,9 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '1.1rem' }}>
|
||||
Comprehensive document analysis and metadata viewer
|
||||
{t('documentDetails.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
|
|
@ -586,16 +588,16 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
<Stack spacing={2}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
File Size
|
||||
{t('documentDetails.metadata.fileSize')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{formatFileSize(document.file_size)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload Date
|
||||
{t('documentDetails.metadata.uploadDate')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{formatDate(document.created_at)}
|
||||
|
|
@ -605,7 +607,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
{document.source_type && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Source Type
|
||||
{t('documentDetails.metadata.sourceType')}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={document.source_type.replace('_', ' ').toUpperCase()}
|
||||
|
|
@ -622,7 +624,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
{document.source_path && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Original Path
|
||||
{t('documentDetails.metadata.originalPath')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
|
|
@ -643,7 +645,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
{document.original_created_at && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Original Created
|
||||
{t('documentDetails.metadata.originalCreated')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{formatDate(document.original_created_at)}
|
||||
|
|
@ -654,7 +656,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
{document.original_modified_at && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Original Modified
|
||||
{t('documentDetails.metadata.originalModified')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{formatDate(document.original_modified_at)}
|
||||
|
|
@ -665,10 +667,10 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
{document.has_ocr_text && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
OCR Status
|
||||
{t('documentDetails.metadata.ocrStatus')}
|
||||
</Typography>
|
||||
<Chip
|
||||
label="Text Extracted"
|
||||
<Chip
|
||||
label={t('documentDetails.metadata.textExtracted')}
|
||||
color="success"
|
||||
size="small"
|
||||
icon={<TextIcon sx={{ fontSize: 16 }} />}
|
||||
|
|
@ -680,7 +682,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
{/* Action Buttons */}
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 4 }} justifyContent="center">
|
||||
{document.mime_type?.includes('image') && (
|
||||
<Tooltip title="View Processed Image">
|
||||
<Tooltip title={t('documentDetails.actions.viewProcessedImage')}>
|
||||
<IconButton
|
||||
onClick={handleViewProcessedImage}
|
||||
disabled={processedImageLoading}
|
||||
|
|
@ -701,8 +703,8 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title="Retry OCR">
|
||||
|
||||
<Tooltip title={t('documentDetails.actions.retryOcr')}>
|
||||
<IconButton
|
||||
onClick={handleRetryOcr}
|
||||
disabled={retryingOcr}
|
||||
|
|
@ -722,8 +724,8 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Retry History">
|
||||
|
||||
<Tooltip title={t('documentDetails.actions.retryHistory')}>
|
||||
<IconButton
|
||||
onClick={handleShowRetryHistory}
|
||||
sx={{
|
||||
|
|
@ -779,10 +781,10 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
<CardContent sx={{ p: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
🔍 Extracted Text (OCR)
|
||||
{t('documentDetails.ocr.title')}
|
||||
</Typography>
|
||||
{ocrData?.ocr_text && (
|
||||
<Tooltip title="Expand to view full text with search">
|
||||
<Tooltip title={t('documentDetails.ocr.expandTooltip')}>
|
||||
<IconButton
|
||||
onClick={() => setExpandedOcrText(true)}
|
||||
sx={{
|
||||
|
|
@ -797,18 +799,18 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
>
|
||||
<ExpandIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="button" sx={{ fontSize: '0.75rem' }}>
|
||||
Expand
|
||||
{t('documentDetails.ocr.expand')}
|
||||
</Typography>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
{ocrLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||
<CircularProgress size={32} sx={{ mr: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Loading OCR analysis...
|
||||
{t('documentDetails.ocr.loading')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : ocrData ? (
|
||||
|
|
@ -816,9 +818,9 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
{/* Enhanced OCR Stats */}
|
||||
<Box sx={{ mb: 4, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
{ocrData.ocr_confidence && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: mode === 'light' ? modernTokens.colors.primary[100] : modernTokens.colors.primary[800],
|
||||
border: `1px solid ${mode === 'light' ? modernTokens.colors.primary[300] : modernTokens.colors.primary[600]}`,
|
||||
|
|
@ -830,14 +832,14 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
{Math.round(ocrData.ocr_confidence)}%
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Confidence
|
||||
{t('documentDetails.ocr.confidence')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{ocrData.ocr_word_count && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: mode === 'light' ? modernTokens.colors.secondary[100] : modernTokens.colors.secondary[800],
|
||||
border: `1px solid ${mode === 'light' ? modernTokens.colors.secondary[300] : modernTokens.colors.secondary[600]}`,
|
||||
|
|
@ -849,14 +851,14 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
{ocrData.ocr_word_count.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Words
|
||||
{t('documentDetails.ocr.words')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{ocrData.ocr_processing_time_ms && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: mode === 'light' ? modernTokens.colors.info[100] : modernTokens.colors.info[800],
|
||||
border: `1px solid ${mode === 'light' ? modernTokens.colors.info[300] : modernTokens.colors.info[600]}`,
|
||||
|
|
@ -868,7 +870,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
{ocrData.ocr_processing_time_ms}ms
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Processing Time
|
||||
{t('documentDetails.ocr.processingTime')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
|
@ -876,15 +878,15 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
|
||||
{/* OCR Error Display */}
|
||||
{ocrData.ocr_error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
mb: 3,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
OCR Processing Error
|
||||
{t('documentDetails.ocr.error')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{ocrData.ocr_error}</Typography>
|
||||
</Alert>
|
||||
|
|
@ -934,7 +936,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ fontStyle: 'italic', textAlign: 'center', py: 4 }}>
|
||||
No OCR text available for this document.
|
||||
{t('documentDetails.ocr.noText')}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
|
|
@ -943,19 +945,19 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
{ocrData.ocr_completed_at && (
|
||||
<Box sx={{ mt: 3, pt: 3, borderTop: `1px solid ${theme.palette.divider}` }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
✅ Processing completed: {new Date(ocrData.ocr_completed_at).toLocaleString()}
|
||||
{t('documentDetails.ocr.completed', { date: new Date(ocrData.ocr_completed_at).toLocaleString() })}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Alert
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
OCR text is available but failed to load. Please try refreshing the page.
|
||||
{t('documentDetails.ocr.loadFailed')}
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -985,7 +987,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
<CardContent sx={{ p: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
🏷️ Tags & Labels
|
||||
{t('documentDetails.tagsLabels.title')}
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<EditIcon />}
|
||||
|
|
@ -998,15 +1000,15 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
Edit Labels
|
||||
{t('documentDetails.actions.editLabels')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Tags */}
|
||||
{document.tags && document.tags.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Tags
|
||||
{t('documentDetails.tagsLabels.tags')}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
|
||||
{document.tags.map((tag, index) => (
|
||||
|
|
@ -1028,7 +1030,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
{/* Labels */}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Labels
|
||||
{t('documentDetails.tagsLabels.labels')}
|
||||
</Typography>
|
||||
{documentLabels.length > 0 ? (
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
|
||||
|
|
@ -1047,7 +1049,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
No labels assigned to this document
|
||||
{t('documentDetails.tagsLabels.noLabels')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
|
@ -1070,22 +1072,22 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Extracted Text (OCR)
|
||||
{t('documentDetails.dialogs.ocrText.title')}
|
||||
</Typography>
|
||||
{ocrData && (
|
||||
<Stack direction="row" spacing={1}>
|
||||
{ocrData.ocr_confidence && (
|
||||
<Chip
|
||||
label={`${Math.round(ocrData.ocr_confidence)}% confidence`}
|
||||
color="primary"
|
||||
size="small"
|
||||
<Chip
|
||||
label={t('documentDetails.dialogs.ocrText.confidence', { percent: Math.round(ocrData.ocr_confidence) })}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
{ocrData.ocr_word_count && (
|
||||
<Chip
|
||||
label={`${ocrData.ocr_word_count} words`}
|
||||
color="secondary"
|
||||
size="small"
|
||||
<Chip
|
||||
label={t('documentDetails.dialogs.ocrText.words', { count: ocrData.ocr_word_count })}
|
||||
color="secondary"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
|
@ -1097,14 +1099,14 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" sx={{ ml: 2 }}>
|
||||
Loading OCR text...
|
||||
{t('documentDetails.dialogs.ocrText.loading')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{ocrData && ocrData.ocr_error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
OCR Error: {ocrData.ocr_error}
|
||||
{t('documentDetails.dialogs.ocrText.error', { message: ocrData.ocr_error })}
|
||||
</Alert>
|
||||
)}
|
||||
<Paper
|
||||
|
|
@ -1126,15 +1128,15 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{ocrText || 'No OCR text available for this document.'}
|
||||
{ocrText || t('documentDetails.dialogs.ocrText.noText')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
{ocrData && (ocrData.ocr_processing_time_ms || ocrData.ocr_completed_at) && (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'grey.200' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{ocrData.ocr_processing_time_ms && `Processing time: ${ocrData.ocr_processing_time_ms}ms`}
|
||||
{ocrData.ocr_processing_time_ms && t('documentDetails.dialogs.ocrText.processingTime', { time: ocrData.ocr_processing_time_ms })}
|
||||
{ocrData.ocr_processing_time_ms && ocrData.ocr_completed_at && ' • '}
|
||||
{ocrData.ocr_completed_at && `Completed: ${new Date(ocrData.ocr_completed_at).toLocaleString()}`}
|
||||
{ocrData.ocr_completed_at && t('documentDetails.dialogs.ocrText.completed', { date: new Date(ocrData.ocr_completed_at).toLocaleString() })}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
|
@ -1143,7 +1145,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowOcrDialog(false)}>
|
||||
Close
|
||||
{t('common.actions.close')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
@ -1158,7 +1160,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
maxWidth="lg"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
sx: {
|
||||
height: '90vh',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
}
|
||||
|
|
@ -1167,23 +1169,23 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
🔍 Extracted Text (OCR) - Full View
|
||||
{t('documentDetails.dialogs.ocrExpanded.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{ocrData && (
|
||||
<Stack direction="row" spacing={1}>
|
||||
{ocrData.ocr_confidence && (
|
||||
<Chip
|
||||
label={`${Math.round(ocrData.ocr_confidence)}% confidence`}
|
||||
color="primary"
|
||||
size="small"
|
||||
<Chip
|
||||
label={t('documentDetails.dialogs.ocrText.confidence', { percent: Math.round(ocrData.ocr_confidence) })}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
{ocrData.ocr_word_count && (
|
||||
<Chip
|
||||
label={`${ocrData.ocr_word_count} words`}
|
||||
color="secondary"
|
||||
size="small"
|
||||
<Chip
|
||||
label={t('documentDetails.dialogs.ocrText.words', { count: ocrData.ocr_word_count })}
|
||||
color="secondary"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
|
@ -1211,7 +1213,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Search within extracted text..."
|
||||
placeholder={t('documentDetails.dialogs.ocrExpanded.searchPlaceholder')}
|
||||
value={ocrSearchTerm}
|
||||
onChange={(e) => setOcrSearchTerm(e.target.value)}
|
||||
InputProps={{
|
||||
|
|
@ -1242,7 +1244,9 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
{(() => {
|
||||
const text = ocrData?.ocr_text || '';
|
||||
const matches = text.toLowerCase().split(ocrSearchTerm.toLowerCase()).length - 1;
|
||||
return matches > 0 ? `${matches} match${matches === 1 ? '' : 'es'} found` : 'No matches found';
|
||||
return matches > 0
|
||||
? t('documentDetails.dialogs.ocrExpanded.matches', { count: matches, plural: matches === 1 ? '' : 'es' })
|
||||
: t('documentDetails.dialogs.ocrExpanded.noMatches');
|
||||
})()}
|
||||
</Typography>
|
||||
)}
|
||||
|
|
@ -1254,14 +1258,14 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" sx={{ ml: 2 }}>
|
||||
Loading OCR text...
|
||||
{t('documentDetails.dialogs.ocrExpanded.loading')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{ocrData && ocrData.ocr_error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
OCR Error: {ocrData.ocr_error}
|
||||
{t('documentDetails.dialogs.ocrExpanded.error', { message: ocrData.ocr_error })}
|
||||
</Alert>
|
||||
)}
|
||||
<Paper
|
||||
|
|
@ -1290,16 +1294,16 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
'<mark style="background-color: #ffeb3b; color: #000; padding: 2px 4px; border-radius: 2px;">$1</mark>'
|
||||
)
|
||||
: ocrData.ocr_text
|
||||
) : 'No OCR text available for this document.'
|
||||
) : t('documentDetails.dialogs.ocrExpanded.noText')
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
{ocrData && (ocrData.ocr_processing_time_ms || ocrData.ocr_completed_at) && (
|
||||
<Box sx={{ mt: 3, pt: 2, borderTop: `1px solid ${theme.palette.divider}` }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{ocrData.ocr_processing_time_ms && `Processing time: ${ocrData.ocr_processing_time_ms}ms`}
|
||||
{ocrData.ocr_processing_time_ms && t('documentDetails.dialogs.ocrText.processingTime', { time: ocrData.ocr_processing_time_ms })}
|
||||
{ocrData.ocr_processing_time_ms && ocrData.ocr_completed_at && ' • '}
|
||||
{ocrData.ocr_completed_at && `Completed: ${new Date(ocrData.ocr_completed_at).toLocaleString()}`}
|
||||
{ocrData.ocr_completed_at && t('documentDetails.dialogs.ocrText.completed', { date: new Date(ocrData.ocr_completed_at).toLocaleString() })}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
|
@ -1331,7 +1335,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
size="small"
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Download
|
||||
{t('common.actions.download')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -1347,7 +1351,7 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowViewDialog(false)}>
|
||||
Close
|
||||
{t('common.actions.close')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
@ -1360,36 +1364,35 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
Processed Image - OCR Enhancement Applied
|
||||
{t('documentDetails.dialogs.processedImage.title')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{processedImageUrl ? (
|
||||
<Box sx={{ textAlign: 'center', py: 2 }}>
|
||||
<img
|
||||
<img
|
||||
src={processedImageUrl}
|
||||
alt="Processed image that was fed to OCR"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '70vh',
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '70vh',
|
||||
objectFit: 'contain',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ mt: 2, color: 'text.secondary' }}>
|
||||
This is the enhanced image that was actually processed by the OCR engine.
|
||||
You can adjust OCR enhancement settings in the Settings page.
|
||||
{t('documentDetails.dialogs.processedImage.description')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography>No processed image available</Typography>
|
||||
<Typography>{t('documentDetails.dialogs.processedImage.noImage')}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowProcessedImageDialog(false)}>
|
||||
Close
|
||||
{t('common.actions.close')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
@ -1402,19 +1405,19 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
Edit Document Labels
|
||||
{t('documentDetails.dialogs.editLabels.title')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Select labels to assign to this document
|
||||
{t('documentDetails.dialogs.editLabels.description')}
|
||||
</Typography>
|
||||
<LabelSelector
|
||||
selectedLabels={documentLabels}
|
||||
availableLabels={availableLabels}
|
||||
onLabelsChange={setDocumentLabels}
|
||||
onCreateLabel={handleCreateLabel}
|
||||
placeholder="Choose labels for this document..."
|
||||
placeholder={t('documentDetails.dialogs.editLabels.placeholder')}
|
||||
size="medium"
|
||||
disabled={labelsLoading}
|
||||
/>
|
||||
|
|
@ -1422,14 +1425,14 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowLabelDialog(false)}>
|
||||
Cancel
|
||||
{t('common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => handleSaveLabels(documentLabels)}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Save Labels
|
||||
{t('documentDetails.dialogs.editLabels.saveLabels')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
@ -1455,37 +1458,35 @@ const DocumentDetailsPage: React.FC = () => {
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DeleteIcon color="error" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Delete Document
|
||||
{t('documentDetails.dialogs.delete.title')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
This action cannot be undone.
|
||||
{t('documentDetails.dialogs.delete.warning')}
|
||||
</Alert>
|
||||
<Typography variant="body1">
|
||||
Are you sure you want to delete <strong>{document?.original_filename}</strong>?
|
||||
</Typography>
|
||||
<Typography variant="body1" dangerouslySetInnerHTML={{ __html: t('documentDetails.dialogs.delete.message', { filename: document?.original_filename }) }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
This will permanently remove the document and all associated data including OCR text, labels, and processing history.
|
||||
{t('documentDetails.dialogs.delete.details')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
<Button
|
||||
onClick={() => setDeleteConfirmOpen(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
{t('common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleDeleteDocument}
|
||||
disabled={deleting}
|
||||
startIcon={deleting ? <CircularProgress size={16} /> : <DeleteIcon />}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete Document'}
|
||||
{deleting ? t('documentDetails.dialogs.delete.deleting') : t('documentDetails.dialogs.delete.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
|
|
@ -179,6 +180,7 @@ interface IgnoredFilesStats {
|
|||
}
|
||||
|
||||
const DocumentManagementPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const [currentTab, setCurrentTab] = useState(0);
|
||||
|
|
@ -259,22 +261,22 @@ const DocumentManagementPage: React.FC = () => {
|
|||
console.error('Failed to fetch failed documents:', error);
|
||||
|
||||
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
|
||||
let errorMessage = 'Failed to load failed documents';
|
||||
|
||||
let errorMessage = t('documentManagement.errors.loadFailedDocuments');
|
||||
|
||||
// Handle specific document management errors
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
|
||||
errorMessage = 'Your session has expired. Please refresh the page and log in again.';
|
||||
errorMessage = t('documentManagement.errors.sessionExpired');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
|
||||
errorMessage = 'You do not have permission to view failed documents.';
|
||||
errorMessage = t('documentManagement.errors.permissionDenied');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_NOT_FOUND)) {
|
||||
errorMessage = 'No failed documents found or they may have been processed.';
|
||||
errorMessage = t('documentManagement.errors.noFailedDocumentsFound');
|
||||
} else if (errorInfo.category === 'network') {
|
||||
errorMessage = 'Network error. Please check your connection and try again.';
|
||||
errorMessage = t('documentManagement.errors.networkError');
|
||||
} else if (errorInfo.category === 'server') {
|
||||
errorMessage = 'Server error. Please try again later.';
|
||||
errorMessage = t('documentManagement.errors.serverError');
|
||||
} else {
|
||||
errorMessage = errorInfo.message || 'Failed to load failed documents';
|
||||
errorMessage = errorInfo.message || t('documentManagement.errors.loadFailedDocuments');
|
||||
}
|
||||
|
||||
setSnackbar({
|
||||
|
|
@ -304,20 +306,20 @@ const DocumentManagementPage: React.FC = () => {
|
|||
console.error('Failed to fetch duplicates:', error);
|
||||
|
||||
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
|
||||
let errorMessage = 'Failed to load duplicate documents';
|
||||
|
||||
let errorMessage = t('documentManagement.errors.loadDuplicates');
|
||||
|
||||
// Handle specific duplicate fetch errors
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
|
||||
errorMessage = 'Your session has expired. Please refresh the page and log in again.';
|
||||
errorMessage = t('documentManagement.errors.sessionExpired');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
|
||||
errorMessage = 'You do not have permission to view duplicate documents.';
|
||||
errorMessage = t('documentManagement.errors.permissionDeniedDuplicates');
|
||||
} else if (errorInfo.category === 'network') {
|
||||
errorMessage = 'Network error. Please check your connection and try again.';
|
||||
errorMessage = t('documentManagement.errors.networkError');
|
||||
} else if (errorInfo.category === 'server') {
|
||||
errorMessage = 'Server error. Please try again later.';
|
||||
errorMessage = t('documentManagement.errors.serverError');
|
||||
} else {
|
||||
errorMessage = errorInfo.message || 'Failed to load duplicate documents';
|
||||
errorMessage = errorInfo.message || t('documentManagement.errors.loadDuplicates');
|
||||
}
|
||||
|
||||
setSnackbar({
|
||||
|
|
@ -373,7 +375,10 @@ const DocumentManagementPage: React.FC = () => {
|
|||
if (response.data.success) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: `OCR retry queued for "${document.filename}". Estimated wait time: ${response.data.estimated_wait_minutes || 'Unknown'} minutes.`,
|
||||
message: t('documentManagement.retry.queuedSuccess', {
|
||||
filename: document.filename,
|
||||
minutes: response.data.estimated_wait_minutes || t('documentManagement.retry.unknown')
|
||||
}),
|
||||
severity: 'success'
|
||||
});
|
||||
|
||||
|
|
@ -382,7 +387,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
} else {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: response.data.message || 'Failed to retry OCR',
|
||||
message: response.data.message || t('documentManagement.retry.failed'),
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
|
|
@ -390,24 +395,24 @@ const DocumentManagementPage: React.FC = () => {
|
|||
console.error('Failed to retry OCR:', error);
|
||||
|
||||
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
|
||||
let errorMessage = 'Failed to retry OCR processing';
|
||||
|
||||
let errorMessage = t('documentManagement.retry.processingFailed');
|
||||
|
||||
// Handle specific OCR retry errors
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_NOT_FOUND)) {
|
||||
errorMessage = 'Document not found. It may have been deleted or processed already.';
|
||||
errorMessage = t('documentManagement.errors.documentNotFound');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.DOCUMENT_OCR_FAILED)) {
|
||||
errorMessage = 'Document cannot be retried due to processing issues. Please check the document format.';
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
errorMessage = t('documentManagement.errors.cannotRetry');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
|
||||
errorMessage = 'Your session has expired. Please refresh the page and log in again.';
|
||||
errorMessage = t('documentManagement.errors.sessionExpired');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
|
||||
errorMessage = 'You do not have permission to retry OCR processing.';
|
||||
errorMessage = t('documentManagement.errors.permissionDeniedRetry');
|
||||
} else if (errorInfo.category === 'server') {
|
||||
errorMessage = 'Server error. Please try again later or contact support.';
|
||||
errorMessage = t('documentManagement.errors.serverErrorSupport');
|
||||
} else if (errorInfo.category === 'network') {
|
||||
errorMessage = 'Network error. Please check your connection and try again.';
|
||||
errorMessage = t('documentManagement.errors.networkError');
|
||||
} else {
|
||||
errorMessage = errorInfo.message || 'Failed to retry OCR processing';
|
||||
errorMessage = errorInfo.message || t('documentManagement.retry.processingFailed');
|
||||
}
|
||||
|
||||
setSnackbar({
|
||||
|
|
@ -431,16 +436,19 @@ const DocumentManagementPage: React.FC = () => {
|
|||
if (response.data.queued_count > 0) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: `Successfully queued ${response.data.queued_count} documents for OCR retry. Estimated processing time: ${Math.ceil(response.data.estimated_total_time_minutes)} minutes.`,
|
||||
message: t('documentManagement.retry.bulkSuccess', {
|
||||
count: response.data.queued_count,
|
||||
minutes: Math.ceil(response.data.estimated_total_time_minutes)
|
||||
}),
|
||||
severity: 'success'
|
||||
});
|
||||
|
||||
|
||||
// Refresh all tabs since we're retrying all documents
|
||||
await refreshCurrentTab();
|
||||
} else {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'No documents found to retry',
|
||||
message: t('documentManagement.retry.noDocuments'),
|
||||
severity: 'info'
|
||||
});
|
||||
}
|
||||
|
|
@ -448,7 +456,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
console.error('Error retrying all documents:', error);
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Failed to retry documents. Please try again.',
|
||||
message: t('documentManagement.retry.bulkFailed'),
|
||||
severity: 'error'
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -464,16 +472,18 @@ const DocumentManagementPage: React.FC = () => {
|
|||
if (response.data.requeued_count > 0) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: `Successfully queued ${response.data.requeued_count} failed documents for OCR retry. Check the queue stats for progress.`,
|
||||
message: t('documentManagement.retry.requeuedSuccess', {
|
||||
count: response.data.requeued_count
|
||||
}),
|
||||
severity: 'success'
|
||||
});
|
||||
|
||||
|
||||
// Refresh the list to update status
|
||||
await fetchFailedDocuments();
|
||||
} else {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'No failed documents found to retry',
|
||||
message: t('documentManagement.retry.noFailedDocuments'),
|
||||
severity: 'info'
|
||||
});
|
||||
}
|
||||
|
|
@ -481,7 +491,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
console.error('Failed to retry all failed OCR:', error);
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Failed to retry all failed OCR documents',
|
||||
message: t('documentManagement.retry.requeuedFailed'),
|
||||
severity: 'error'
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -493,7 +503,11 @@ const DocumentManagementPage: React.FC = () => {
|
|||
const handleBulkRetrySuccess = (result: BulkOcrRetryResponse) => {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: `Successfully queued ${result.queued_count} of ${result.matched_count} documents for retry. Estimated processing time: ${Math.round(result.estimated_total_time_minutes)} minutes.`,
|
||||
message: t('documentManagement.retry.advancedSuccess', {
|
||||
queued: result.queued_count,
|
||||
matched: result.matched_count,
|
||||
minutes: Math.round(result.estimated_total_time_minutes)
|
||||
}),
|
||||
severity: 'success'
|
||||
});
|
||||
fetchFailedDocuments(); // Refresh the list
|
||||
|
|
@ -574,20 +588,20 @@ const DocumentManagementPage: React.FC = () => {
|
|||
console.error('Failed to fetch ignored files:', error);
|
||||
|
||||
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
|
||||
let errorMessage = 'Failed to load ignored files';
|
||||
|
||||
let errorMessage = t('documentManagement.errors.loadIgnoredFiles');
|
||||
|
||||
// Handle specific ignored files errors
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_SESSION_EXPIRED) ||
|
||||
ErrorHelper.isErrorCode(error, ErrorCodes.USER_TOKEN_EXPIRED)) {
|
||||
errorMessage = 'Your session has expired. Please refresh the page and log in again.';
|
||||
errorMessage = t('documentManagement.errors.sessionExpired');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.USER_PERMISSION_DENIED)) {
|
||||
errorMessage = 'You do not have permission to view ignored files.';
|
||||
errorMessage = t('documentManagement.errors.permissionDeniedIgnored');
|
||||
} else if (errorInfo.category === 'network') {
|
||||
errorMessage = 'Network error. Please check your connection and try again.';
|
||||
errorMessage = t('documentManagement.errors.networkError');
|
||||
} else if (errorInfo.category === 'server') {
|
||||
errorMessage = 'Server error. Please try again later.';
|
||||
errorMessage = t('documentManagement.errors.serverError');
|
||||
} else {
|
||||
errorMessage = errorInfo.message || 'Failed to load ignored files';
|
||||
errorMessage = errorInfo.message || t('documentManagement.errors.loadIgnoredFiles');
|
||||
}
|
||||
|
||||
setSnackbar({
|
||||
|
|
@ -642,7 +656,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: response.data.message || 'Files removed from ignored list',
|
||||
message: response.data.message || t('documentManagement.ignoredFiles.removedSuccess'),
|
||||
severity: 'success'
|
||||
});
|
||||
setSelectedIgnoredFiles(new Set());
|
||||
|
|
@ -652,7 +666,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
} catch (error: any) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: error.response?.data?.message || 'Failed to delete ignored files',
|
||||
message: error.response?.data?.message || t('documentManagement.ignoredFiles.deleteFailed'),
|
||||
severity: 'error'
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -665,7 +679,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
const response = await api.delete(`/ignored-files/${fileId}`);
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: response.data.message || 'File removed from ignored list',
|
||||
message: response.data.message || t('documentManagement.ignoredFiles.fileRemovedSuccess'),
|
||||
severity: 'success'
|
||||
});
|
||||
fetchIgnoredFiles();
|
||||
|
|
@ -673,7 +687,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
} catch (error: any) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: error.response?.data?.message || 'Failed to delete ignored file',
|
||||
message: error.response?.data?.message || t('documentManagement.ignoredFiles.fileDeleteFailed'),
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
|
|
@ -748,7 +762,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
} catch (error) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Failed to preview low confidence documents',
|
||||
message: t('documentManagement.cleanup.previewFailed'),
|
||||
severity: 'error'
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -760,7 +774,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
if (!previewData || previewData.matched_count === 0) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'No documents to delete',
|
||||
message: t('documentManagement.cleanup.noDocuments'),
|
||||
severity: 'warning'
|
||||
});
|
||||
return;
|
||||
|
|
@ -784,7 +798,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
} catch (error) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Failed to delete low confidence documents',
|
||||
message: t('documentManagement.cleanup.deleteFailed'),
|
||||
severity: 'error'
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -801,7 +815,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
} catch (error) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Failed to preview failed documents',
|
||||
message: t('documentManagement.cleanup.previewFailedDocs'),
|
||||
severity: 'error'
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -829,7 +843,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
} catch (error) {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: 'Failed to delete failed documents',
|
||||
message: t('documentManagement.cleanup.deleteFailedDocs'),
|
||||
severity: 'error'
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -849,7 +863,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
<Box sx={{ p: 3 }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Document Management
|
||||
{t('documentManagement.title')}
|
||||
</Typography>
|
||||
<Box display="flex" gap={2}>
|
||||
<Button
|
||||
|
|
@ -867,7 +881,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
}
|
||||
}}
|
||||
>
|
||||
{retryingAll ? 'Retrying All...' : 'Retry All Documents'}
|
||||
{retryingAll ? t('documentManagement.retrying') : t('documentManagement.retryAll')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
|
@ -875,7 +889,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
onClick={refreshCurrentTab}
|
||||
disabled={loading || duplicatesLoading || retryingAll}
|
||||
>
|
||||
Refresh
|
||||
{t('common.actions.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -903,31 +917,43 @@ const DocumentManagementPage: React.FC = () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip title="View and manage documents that failed during processing (OCR, ingestion, validation, etc.)">
|
||||
<Tooltip title={t('documentManagement.tabs.failedDocumentsTooltip')}>
|
||||
<Tab
|
||||
icon={<ErrorIcon />}
|
||||
label={`Failed Documents${statistics ? ` (${statistics.total_failed})` : ''}`}
|
||||
label={t('documentManagement.tabs.failedDocuments', {
|
||||
count: statistics ? statistics.total_failed : 0,
|
||||
showCount: statistics ? true : false
|
||||
})}
|
||||
iconPosition="start"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Manage and clean up documents with quality issues - low OCR confidence or failed processing">
|
||||
<Tooltip title={t('documentManagement.tabs.cleanupTooltip')}>
|
||||
<Tab
|
||||
icon={<DeleteIcon />}
|
||||
label={`Document Cleanup${(previewData?.matched_count || 0) + (failedPreviewData?.matched_count || 0) > 0 ? ` (${(previewData?.matched_count || 0) + (failedPreviewData?.matched_count || 0)})` : ''}`}
|
||||
label={t('documentManagement.tabs.cleanup', {
|
||||
count: (previewData?.matched_count || 0) + (failedPreviewData?.matched_count || 0),
|
||||
showCount: (previewData?.matched_count || 0) + (failedPreviewData?.matched_count || 0) > 0
|
||||
})}
|
||||
iconPosition="start"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="View and manage duplicate document groups - documents with identical content">
|
||||
<Tooltip title={t('documentManagement.tabs.duplicatesTooltip')}>
|
||||
<Tab
|
||||
icon={<FileCopyIcon />}
|
||||
label={`Duplicate Files${duplicateStatistics ? ` (${duplicateStatistics.total_duplicate_groups})` : ''}`}
|
||||
label={t('documentManagement.tabs.duplicates', {
|
||||
count: duplicateStatistics ? duplicateStatistics.total_duplicate_groups : 0,
|
||||
showCount: duplicateStatistics ? true : false
|
||||
})}
|
||||
iconPosition="start"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Manage files that have been ignored during sync operations">
|
||||
<Tooltip title={t('documentManagement.tabs.ignoredFilesTooltip')}>
|
||||
<Tab
|
||||
icon={<BlockIcon />}
|
||||
label={`Ignored Files${ignoredFilesStats ? ` (${ignoredFilesStats.total_ignored_files})` : ''}`}
|
||||
label={t('documentManagement.tabs.ignoredFiles', {
|
||||
count: ignoredFilesStats ? ignoredFilesStats.total_ignored_files : 0,
|
||||
showCount: ignoredFilesStats ? true : false
|
||||
})}
|
||||
iconPosition="start"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
|
@ -945,7 +971,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
<CardContent>
|
||||
<Typography variant="h6" color="error">
|
||||
<ErrorIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Total Failed
|
||||
{t('documentManagement.stats.totalFailed')}
|
||||
</Typography>
|
||||
<Typography variant="h3" color="error.main">
|
||||
{statistics.total_failed}
|
||||
|
|
@ -960,7 +986,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
{retryingAll ? 'Retrying...' : 'Retry Failed Only'}
|
||||
{retryingAll ? t('documentManagement.retrying') : t('documentManagement.retryFailedOnly')}
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
|
@ -970,7 +996,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" mb={2}>
|
||||
Failure Categories
|
||||
{t('documentManagement.stats.failureCategories')}
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
{statistics?.by_reason ? Object.entries(statistics.by_reason).map(([reason, count]) => (
|
||||
|
|
@ -983,7 +1009,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
/>
|
||||
)) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No failure data available
|
||||
{t('documentManagement.stats.noFailureData')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
|
@ -999,18 +1025,18 @@ const DocumentManagementPage: React.FC = () => {
|
|||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">Advanced Retry Options</Typography>
|
||||
<Typography variant="h6">{t('documentManagement.advancedRetry.title')}</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setBulkRetryModalOpen(true)}
|
||||
disabled={!statistics || statistics.total_failed === 0}
|
||||
startIcon={<RefreshIcon />}
|
||||
>
|
||||
Advanced Retry
|
||||
{t('documentManagement.advancedRetry.button')}
|
||||
</Button>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Use advanced filtering and selection options to retry specific subsets of failed documents based on file type, failure reason, size, and more.
|
||||
{t('documentManagement.advancedRetry.description')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -1023,42 +1049,42 @@ const DocumentManagementPage: React.FC = () => {
|
|||
{/* Filter Controls */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" mb={2}>Filter Options</Typography>
|
||||
<Typography variant="h6" mb={2}>{t('documentManagement.filters.title')}</Typography>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
label="Filter by Stage"
|
||||
label={t('documentManagement.filters.stage')}
|
||||
select
|
||||
value={failedDocumentsFilters.stage || ''}
|
||||
onChange={(e) => setFailedDocumentsFilters(prev => ({ ...prev, stage: e.target.value || undefined }))}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="">All Stages</MenuItem>
|
||||
<MenuItem value="ocr">OCR Processing</MenuItem>
|
||||
<MenuItem value="ingestion">Document Ingestion</MenuItem>
|
||||
<MenuItem value="validation">Validation</MenuItem>
|
||||
<MenuItem value="storage">File Storage</MenuItem>
|
||||
<MenuItem value="processing">Processing</MenuItem>
|
||||
<MenuItem value="sync">Synchronization</MenuItem>
|
||||
<MenuItem value="">{t('documentManagement.filters.allStages')}</MenuItem>
|
||||
<MenuItem value="ocr">{t('documentManagement.filters.stages.ocr')}</MenuItem>
|
||||
<MenuItem value="ingestion">{t('documentManagement.filters.stages.ingestion')}</MenuItem>
|
||||
<MenuItem value="validation">{t('documentManagement.filters.stages.validation')}</MenuItem>
|
||||
<MenuItem value="storage">{t('documentManagement.filters.stages.storage')}</MenuItem>
|
||||
<MenuItem value="processing">{t('documentManagement.filters.stages.processing')}</MenuItem>
|
||||
<MenuItem value="sync">{t('documentManagement.filters.stages.sync')}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
label="Filter by Reason"
|
||||
label={t('documentManagement.filters.reason')}
|
||||
select
|
||||
value={failedDocumentsFilters.reason || ''}
|
||||
onChange={(e) => setFailedDocumentsFilters(prev => ({ ...prev, reason: e.target.value || undefined }))}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="">All Reasons</MenuItem>
|
||||
<MenuItem value="duplicate_content">Duplicate Content</MenuItem>
|
||||
<MenuItem value="low_ocr_confidence">Low OCR Confidence</MenuItem>
|
||||
<MenuItem value="unsupported_format">Unsupported Format</MenuItem>
|
||||
<MenuItem value="file_too_large">File Too Large</MenuItem>
|
||||
<MenuItem value="file_corrupted">File Corrupted</MenuItem>
|
||||
<MenuItem value="ocr_timeout">OCR Timeout</MenuItem>
|
||||
<MenuItem value="pdf_parsing_error">PDF Parsing Error</MenuItem>
|
||||
<MenuItem value="other">Other</MenuItem>
|
||||
<MenuItem value="">{t('documentManagement.filters.allReasons')}</MenuItem>
|
||||
<MenuItem value="duplicate_content">{t('documentManagement.filters.reasons.duplicateContent')}</MenuItem>
|
||||
<MenuItem value="low_ocr_confidence">{t('documentManagement.filters.reasons.lowConfidence')}</MenuItem>
|
||||
<MenuItem value="unsupported_format">{t('documentManagement.filters.reasons.unsupportedFormat')}</MenuItem>
|
||||
<MenuItem value="file_too_large">{t('documentManagement.filters.reasons.fileTooLarge')}</MenuItem>
|
||||
<MenuItem value="file_corrupted">{t('documentManagement.filters.reasons.fileCorrupted')}</MenuItem>
|
||||
<MenuItem value="ocr_timeout">{t('documentManagement.filters.reasons.ocrTimeout')}</MenuItem>
|
||||
<MenuItem value="pdf_parsing_error">{t('documentManagement.filters.reasons.pdfParsingError')}</MenuItem>
|
||||
<MenuItem value="other">{t('documentManagement.filters.reasons.other')}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
|
|
@ -1068,7 +1094,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
disabled={!failedDocumentsFilters.stage && !failedDocumentsFilters.reason}
|
||||
fullWidth
|
||||
>
|
||||
Clear Filters
|
||||
{t('documentManagement.filters.clearFilters')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
@ -1077,15 +1103,14 @@ const DocumentManagementPage: React.FC = () => {
|
|||
|
||||
{(!documents || documents.length === 0) ? (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
<AlertTitle>Great news!</AlertTitle>
|
||||
No documents have failed OCR processing. All your documents are processing successfully.
|
||||
<AlertTitle>{t('documentManagement.alerts.noFailedTitle')}</AlertTitle>
|
||||
{t('documentManagement.alerts.noFailedMessage')}
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<AlertTitle>Failed Documents Overview</AlertTitle>
|
||||
These documents failed at various stages of processing: ingestion, validation, OCR, storage, etc.
|
||||
Use the filters above to narrow down by failure stage or specific reason. You can retry processing for recoverable failures.
|
||||
<AlertTitle>{t('documentManagement.alerts.overviewTitle')}</AlertTitle>
|
||||
{t('documentManagement.alerts.overviewMessage')}
|
||||
</Alert>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
|
|
@ -1093,11 +1118,11 @@ const DocumentManagementPage: React.FC = () => {
|
|||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell />
|
||||
<TableCell>Document</TableCell>
|
||||
<TableCell>Failure Type</TableCell>
|
||||
<TableCell>Retry Count</TableCell>
|
||||
<TableCell>Last Failed</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
<TableCell>{t('documentManagement.table.document')}</TableCell>
|
||||
<TableCell>{t('documentManagement.table.failureType')}</TableCell>
|
||||
<TableCell>{t('documentManagement.table.retryCount')}</TableCell>
|
||||
<TableCell>{t('documentManagement.table.lastFailed')}</TableCell>
|
||||
<TableCell>{t('documentManagement.table.actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
|
@ -1131,17 +1156,17 @@ const DocumentManagementPage: React.FC = () => {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{document.retry_count} attempts
|
||||
{t('documentManagement.table.attempts', { count: document.retry_count })}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{document.updated_at ? format(new Date(document.updated_at), 'MMM dd, yyyy HH:mm') : 'Unknown'}
|
||||
{document.updated_at ? format(new Date(document.updated_at), 'MMM dd, yyyy HH:mm') : t('documentManagement.table.unknown')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" gap={1}>
|
||||
<Tooltip title="Retry OCR">
|
||||
<Tooltip title={t('documentManagement.actions.retryOcr')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRetryOcr(document)}
|
||||
|
|
@ -1154,7 +1179,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="View Details">
|
||||
<Tooltip title={t('documentManagement.actions.viewDetails')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => showDocumentDetails(document)}
|
||||
|
|
@ -1162,7 +1187,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
<VisibilityIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Retry History">
|
||||
<Tooltip title={t('documentManagement.actions.retryHistory')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleShowRetryHistory(document.id)}
|
||||
|
|
@ -1170,7 +1195,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
<HistoryIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Download Document">
|
||||
<Tooltip title={t('documentManagement.actions.download')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
|
|
@ -1197,29 +1222,29 @@ const DocumentManagementPage: React.FC = () => {
|
|||
borderRadius: 1
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Error Details
|
||||
{t('documentManagement.details.errorDetails')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Failure Reason:</strong>
|
||||
<strong>{t('documentManagement.details.failureReason')}:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{document.failure_reason || document.ocr_failure_reason || 'Not specified'}
|
||||
{document.failure_reason || document.ocr_failure_reason || t('documentManagement.details.notSpecified')}
|
||||
</Typography>
|
||||
|
||||
{/* Show OCR confidence and word count for low confidence failures */}
|
||||
{(document.failure_reason === 'low_ocr_confidence' || document.ocr_failure_reason === 'low_ocr_confidence') && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary" component="div">
|
||||
<strong>OCR Results:</strong>
|
||||
<strong>{t('documentManagement.details.ocrResults')}:</strong>
|
||||
</Typography>
|
||||
<Box component="div" sx={{ mb: 1, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
{document.ocr_confidence !== undefined && document.ocr_confidence !== null && (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<WarningIcon />}
|
||||
label={`${document.ocr_confidence.toFixed(1)}% confidence`}
|
||||
label={t('documentManagement.details.confidencePercent', { percent: document.ocr_confidence.toFixed(1) })}
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
|
@ -1228,7 +1253,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
<Chip
|
||||
size="small"
|
||||
icon={<FindInPageIcon />}
|
||||
label={`${document.ocr_word_count} words found`}
|
||||
label={t('documentManagement.details.wordsFound', { count: document.ocr_word_count })}
|
||||
color="info"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
|
@ -1238,7 +1263,7 @@ const DocumentManagementPage: React.FC = () => {
|
|||
)}
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Error Message:</strong>
|
||||
<strong>{t('documentManagement.details.errorMessage')}:</strong>
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
|
|
@ -1251,21 +1276,21 @@ const DocumentManagementPage: React.FC = () => {
|
|||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{document.error_message || document.ocr_error || 'No error message available'}
|
||||
{document.error_message || document.ocr_error || t('documentManagement.details.noErrorMessage')}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Last Attempt:</strong>
|
||||
<strong>{t('documentManagement.details.lastAttempt')}:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{document.last_attempt_at
|
||||
? format(new Date(document.last_attempt_at), 'PPpp')
|
||||
: 'No previous attempts'}
|
||||
: t('documentManagement.details.noPreviousAttempts')}
|
||||
</Typography>
|
||||
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>File Created:</strong>
|
||||
<strong>{t('documentManagement.details.fileCreated')}:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{format(new Date(document.created_at), 'PPpp')}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
|
|
@ -98,6 +99,7 @@ type SortField = 'created_at' | 'original_filename' | 'file_size';
|
|||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
const DocumentsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const api = useApi();
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
|
|
@ -157,7 +159,7 @@ const DocumentsPage: React.FC = () => {
|
|||
setDocuments(response.data.documents || []);
|
||||
setPagination(response.data.pagination || { total: 0, limit: 20, offset: 0, has_more: false });
|
||||
} catch (err) {
|
||||
setError('Failed to load documents');
|
||||
setError(t('common.status.error'));
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -438,12 +440,12 @@ const DocumentsPage: React.FC = () => {
|
|||
|
||||
const getOcrStatusChip = (doc: Document) => {
|
||||
if (!doc.ocr_status) return null;
|
||||
|
||||
|
||||
const statusConfig = {
|
||||
'completed': { color: 'success' as const, label: doc.ocr_confidence ? `OCR ${Math.round(doc.ocr_confidence)}%` : 'OCR Done' },
|
||||
'processing': { color: 'warning' as const, label: 'Processing...' },
|
||||
'failed': { color: 'error' as const, label: 'OCR Failed' },
|
||||
'pending': { color: 'default' as const, label: 'Pending' },
|
||||
'completed': { color: 'success' as const, label: doc.ocr_confidence ? t('documents.ocrStatus.confidence', { percent: Math.round(doc.ocr_confidence) }) : t('documents.ocrStatus.done') },
|
||||
'processing': { color: 'warning' as const, label: t('documents.ocrStatus.processing') },
|
||||
'failed': { color: 'error' as const, label: t('documents.ocrStatus.failed') },
|
||||
'pending': { color: 'default' as const, label: t('documents.ocrStatus.pending') },
|
||||
};
|
||||
|
||||
const config = statusConfig[doc.ocr_status as keyof typeof statusConfig];
|
||||
|
|
@ -490,10 +492,10 @@ const DocumentsPage: React.FC = () => {
|
|||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Documents
|
||||
{t('documents.title')}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Manage and explore your document library
|
||||
{t('documents.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
|
@ -507,7 +509,7 @@ const DocumentsPage: React.FC = () => {
|
|||
}}>
|
||||
{/* Search */}
|
||||
<TextField
|
||||
placeholder="Search documents..."
|
||||
placeholder={t('documents.search.placeholder')}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
value={searchQuery}
|
||||
|
|
@ -545,22 +547,22 @@ const DocumentsPage: React.FC = () => {
|
|||
size="small"
|
||||
color={selectionMode ? "secondary" : "primary"}
|
||||
>
|
||||
{selectionMode ? 'Cancel' : 'Select'}
|
||||
{selectionMode ? t('documents.selection.cancel') : t('documents.selection.select')}
|
||||
</Button>
|
||||
|
||||
{/* OCR Filter */}
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>OCR Status</InputLabel>
|
||||
<InputLabel>{t('documents.filters.ocrStatus')}</InputLabel>
|
||||
<Select
|
||||
value={ocrFilter}
|
||||
label="OCR Status"
|
||||
label={t('documents.filters.ocrStatus')}
|
||||
onChange={handleOcrFilterChange}
|
||||
>
|
||||
<MenuItem value="">All</MenuItem>
|
||||
<MenuItem value="completed">Completed</MenuItem>
|
||||
<MenuItem value="processing">Processing</MenuItem>
|
||||
<MenuItem value="failed">Failed</MenuItem>
|
||||
<MenuItem value="pending">Pending</MenuItem>
|
||||
<MenuItem value="">{t('documents.filters.all')}</MenuItem>
|
||||
<MenuItem value="completed">{t('documents.filters.completed')}</MenuItem>
|
||||
<MenuItem value="processing">{t('documents.filters.processing')}</MenuItem>
|
||||
<MenuItem value="failed">{t('documents.filters.failed')}</MenuItem>
|
||||
<MenuItem value="pending">{t('documents.filters.pending')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
|
|
@ -571,7 +573,7 @@ const DocumentsPage: React.FC = () => {
|
|||
onClick={handleSortMenuClick}
|
||||
size="small"
|
||||
>
|
||||
Sort
|
||||
{t('documents.sort.label')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
|
|
@ -588,7 +590,10 @@ const DocumentsPage: React.FC = () => {
|
|||
color: 'primary.contrastText'
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ flexGrow: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{selectedDocuments.size > 999 ? `${Math.floor(selectedDocuments.size/1000)}K` : selectedDocuments.size} of {sortedDocuments.length > 999 ? `${Math.floor(sortedDocuments.length/1000)}K` : sortedDocuments.length} documents selected
|
||||
{t('documents.selection.count', {
|
||||
count: selectedDocuments.size > 999 ? `${Math.floor(selectedDocuments.size/1000)}K` : selectedDocuments.size,
|
||||
total: sortedDocuments.length > 999 ? `${Math.floor(sortedDocuments.length/1000)}K` : sortedDocuments.length
|
||||
})}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="text"
|
||||
|
|
@ -597,7 +602,7 @@ const DocumentsPage: React.FC = () => {
|
|||
size="small"
|
||||
sx={{ color: 'primary.contrastText' }}
|
||||
>
|
||||
{selectedDocuments.size === sortedDocuments.length ? 'Deselect All' : 'Select All'}
|
||||
{selectedDocuments.size === sortedDocuments.length ? t('documents.selection.deselectAll') : t('documents.selection.selectAll')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
|
|
@ -607,7 +612,7 @@ const DocumentsPage: React.FC = () => {
|
|||
size="small"
|
||||
color="error"
|
||||
>
|
||||
Delete Selected ({selectedDocuments.size > 999 ? `${Math.floor(selectedDocuments.size/1000)}K` : selectedDocuments.size})
|
||||
{t('documents.selection.deleteSelected', { count: selectedDocuments.size > 999 ? `${Math.floor(selectedDocuments.size/1000)}K` : selectedDocuments.size })}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
|
@ -620,27 +625,27 @@ const DocumentsPage: React.FC = () => {
|
|||
>
|
||||
<MenuItem onClick={() => handleSortChange('created_at', 'desc')}>
|
||||
<ListItemIcon><DateIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Newest First</ListItemText>
|
||||
<ListItemText>{t('documents.sort.newestFirst')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleSortChange('created_at', 'asc')}>
|
||||
<ListItemIcon><DateIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Oldest First</ListItemText>
|
||||
<ListItemText>{t('documents.sort.oldestFirst')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleSortChange('original_filename', 'asc')}>
|
||||
<ListItemIcon><TextIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Name A-Z</ListItemText>
|
||||
<ListItemText>{t('documents.sort.nameAZ')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleSortChange('original_filename', 'desc')}>
|
||||
<ListItemIcon><TextIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Name Z-A</ListItemText>
|
||||
<ListItemText>{t('documents.sort.nameZA')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleSortChange('file_size', 'desc')}>
|
||||
<ListItemIcon><SizeIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Largest First</ListItemText>
|
||||
<ListItemText>{t('documents.sort.largestFirst')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleSortChange('file_size', 'asc')}>
|
||||
<ListItemIcon><SizeIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Smallest First</ListItemText>
|
||||
<ListItemText>{t('documents.sort.smallestFirst')}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
|
|
@ -655,25 +660,25 @@ const DocumentsPage: React.FC = () => {
|
|||
handleDocMenuClose();
|
||||
}}>
|
||||
<ListItemIcon><DownloadIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Download</ListItemText>
|
||||
<ListItemText>{t('common.actions.download')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
if (selectedDoc) navigate(`/documents/${selectedDoc.id}`);
|
||||
handleDocMenuClose();
|
||||
<MenuItem onClick={() => {
|
||||
if (selectedDoc) navigate(`/documents/${selectedDoc.id}`);
|
||||
handleDocMenuClose();
|
||||
}}>
|
||||
<ListItemIcon><ViewIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>View Details</ListItemText>
|
||||
<ListItemText>{t('common.actions.viewDetails')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
if (selectedDoc) handleEditDocumentLabels(selectedDoc);
|
||||
handleDocMenuClose();
|
||||
<MenuItem onClick={() => {
|
||||
if (selectedDoc) handleEditDocumentLabels(selectedDoc);
|
||||
handleDocMenuClose();
|
||||
}}>
|
||||
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Edit Labels</ListItemText>
|
||||
<ListItemText>{t('documents.actions.editLabels')}</ListItemText>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => {
|
||||
if (selectedDoc) handleRetryOcr(selectedDoc);
|
||||
<MenuItem onClick={() => {
|
||||
if (selectedDoc) handleRetryOcr(selectedDoc);
|
||||
}} disabled={retryingDocument === selectedDoc?.id}>
|
||||
<ListItemIcon>
|
||||
{retryingDocument === selectedDoc?.id ? (
|
||||
|
|
@ -683,21 +688,21 @@ const DocumentsPage: React.FC = () => {
|
|||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
{retryingDocument === selectedDoc?.id ? 'Retrying OCR...' : 'Retry OCR'}
|
||||
{retryingDocument === selectedDoc?.id ? t('documents.actions.retryingOcr') : t('documents.actions.retryOcr')}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
if (selectedDoc) handleShowRetryHistory(selectedDoc.id);
|
||||
<MenuItem onClick={() => {
|
||||
if (selectedDoc) handleShowRetryHistory(selectedDoc.id);
|
||||
}}>
|
||||
<ListItemIcon><HistoryIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Retry History</ListItemText>
|
||||
<ListItemText>{t('documents.actions.retryHistory')}</ListItemText>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => {
|
||||
if (selectedDoc) handleDeleteClick(selectedDoc);
|
||||
<MenuItem onClick={() => {
|
||||
if (selectedDoc) handleDeleteClick(selectedDoc);
|
||||
}}>
|
||||
<ListItemIcon><DeleteIcon fontSize="small" color="error" /></ListItemIcon>
|
||||
<ListItemText>Delete</ListItemText>
|
||||
<ListItemText>{t('common.actions.delete')}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
|
|
@ -714,10 +719,10 @@ const DocumentsPage: React.FC = () => {
|
|||
}}
|
||||
>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No documents found
|
||||
{t('documents.empty.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{searchQuery ? 'Try adjusting your search terms' : 'Upload your first document to get started'}
|
||||
{searchQuery ? t('documents.empty.searchSubtitle') : t('documents.empty.uploadSubtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
|
|
@ -920,7 +925,7 @@ const DocumentsPage: React.FC = () => {
|
|||
}}
|
||||
fullWidth
|
||||
>
|
||||
Download
|
||||
{t('common.actions.download')}
|
||||
</Button>
|
||||
</CardActions>
|
||||
)}
|
||||
|
|
@ -932,7 +937,7 @@ const DocumentsPage: React.FC = () => {
|
|||
|
||||
{/* Label Edit Dialog */}
|
||||
<Dialog open={labelEditDialogOpen} onClose={() => setLabelEditDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit Document Labels</DialogTitle>
|
||||
<DialogTitle>{t('documents.dialogs.editLabels.title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ pt: 2 }}>
|
||||
<LabelSelector
|
||||
|
|
@ -940,59 +945,62 @@ const DocumentsPage: React.FC = () => {
|
|||
availableLabels={availableLabels}
|
||||
onLabelsChange={setEditingDocumentLabels}
|
||||
onCreateLabel={handleCreateLabel}
|
||||
placeholder="Select labels for this document..."
|
||||
placeholder={t('documents.dialogs.editLabels.placeholder')}
|
||||
size="medium"
|
||||
disabled={labelsLoading}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setLabelEditDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSaveDocumentLabels} variant="contained">Save</Button>
|
||||
<Button onClick={() => setLabelEditDialogOpen(false)}>{t('common.actions.cancel')}</Button>
|
||||
<Button onClick={handleSaveDocumentLabels} variant="contained">{t('common.actions.save')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={handleDeleteCancel} maxWidth="sm">
|
||||
<DialogTitle>Delete Document</DialogTitle>
|
||||
<DialogTitle>{t('documents.dialogs.delete.title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete "{documentToDelete?.original_filename}"?
|
||||
{t('documents.dialogs.delete.message', { filename: documentToDelete?.original_filename })}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
This action cannot be undone. The document file and all associated data will be permanently removed.
|
||||
{t('documents.dialogs.delete.warning')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDeleteCancel} disabled={deleteLoading}>
|
||||
Cancel
|
||||
{t('common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteConfirm}
|
||||
color="error"
|
||||
<Button
|
||||
onClick={handleDeleteConfirm}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={deleteLoading}
|
||||
startIcon={deleteLoading ? <CircularProgress size={16} color="inherit" /> : <DeleteIcon />}
|
||||
>
|
||||
{deleteLoading ? 'Deleting...' : 'Delete'}
|
||||
{deleteLoading ? t('documents.dialogs.delete.deleting') : t('documents.dialogs.delete.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Delete Confirmation Dialog */}
|
||||
<Dialog open={bulkDeleteDialogOpen} onClose={handleBulkDeleteCancel} maxWidth="sm">
|
||||
<DialogTitle>Delete Multiple Documents</DialogTitle>
|
||||
<DialogTitle>{t('documents.dialogs.bulkDelete.title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography gutterBottom>
|
||||
Are you sure you want to delete {selectedDocuments.size} selected document{selectedDocuments.size !== 1 ? 's' : ''}?
|
||||
{t('documents.dialogs.bulkDelete.message', {
|
||||
count: selectedDocuments.size,
|
||||
plural: selectedDocuments.size !== 1 ? 's' : ''
|
||||
})}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
This action cannot be undone. All selected documents and their associated data will be permanently removed.
|
||||
{t('documents.dialogs.bulkDelete.warning')}
|
||||
</Typography>
|
||||
{selectedDocuments.size > 0 && (
|
||||
<Box sx={{ mt: 2, maxHeight: 200, overflow: 'auto' }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Documents to be deleted:
|
||||
{t('documents.dialogs.bulkDelete.listTitle')}
|
||||
</Typography>
|
||||
{Array.from(selectedDocuments).slice(0, 10).map(docId => {
|
||||
const doc = documents.find(d => d.id === docId);
|
||||
|
|
@ -1004,7 +1012,7 @@ const DocumentsPage: React.FC = () => {
|
|||
})}
|
||||
{selectedDocuments.size > 10 && (
|
||||
<Typography variant="body2" sx={{ pl: 1, fontStyle: 'italic' }}>
|
||||
... and {selectedDocuments.size - 10} more
|
||||
{t('documents.dialogs.bulkDelete.moreCount', { count: selectedDocuments.size - 10 })}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
|
@ -1012,16 +1020,19 @@ const DocumentsPage: React.FC = () => {
|
|||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleBulkDeleteCancel} disabled={bulkDeleteLoading}>
|
||||
Cancel
|
||||
{t('common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBulkDeleteConfirm}
|
||||
color="error"
|
||||
<Button
|
||||
onClick={handleBulkDeleteConfirm}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={bulkDeleteLoading}
|
||||
startIcon={bulkDeleteLoading ? <CircularProgress size={16} color="inherit" /> : <DeleteIcon />}
|
||||
>
|
||||
{bulkDeleteLoading ? 'Deleting...' : `Delete ${selectedDocuments.size} Document${selectedDocuments.size !== 1 ? 's' : ''}`}
|
||||
{bulkDeleteLoading ? t('documents.dialogs.delete.deleting') : t('documents.dialogs.bulkDelete.deleteButton', {
|
||||
count: selectedDocuments.size,
|
||||
plural: selectedDocuments.size !== 1 ? 's' : ''
|
||||
})}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
@ -1030,9 +1041,13 @@ const DocumentsPage: React.FC = () => {
|
|||
<Box sx={{ mt: 3 }}>
|
||||
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Showing {pagination.offset + 1}-{Math.min(pagination.offset + pagination.limit, pagination.total)} of {pagination.total} documents
|
||||
{ocrFilter && ` with OCR status: ${ocrFilter}`}
|
||||
{searchQuery && ` matching "${searchQuery}"`}
|
||||
{t('documents.pagination.showing', {
|
||||
start: pagination.offset + 1,
|
||||
end: Math.min(pagination.offset + pagination.limit, pagination.total),
|
||||
total: pagination.total
|
||||
})}
|
||||
{ocrFilter && t('documents.pagination.withOcrStatus', { status: ocrFilter })}
|
||||
{searchQuery && t('documents.pagination.matching', { query: searchQuery })}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { useNotifications } from '../contexts/NotificationContext';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface IgnoredFile {
|
||||
id: string;
|
||||
|
|
@ -81,6 +82,7 @@ interface IgnoredFilesStats {
|
|||
}
|
||||
|
||||
const IgnoredFilesPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [ignoredFiles, setIgnoredFiles] = useState<IgnoredFile[]>([]);
|
||||
|
|
@ -381,11 +383,11 @@ const IgnoredFilesPage: React.FC = () => {
|
|||
sx={{ display: 'flex', alignItems: 'center', textDecoration: 'none' }}
|
||||
>
|
||||
<StorageIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||
Sources
|
||||
{t('navigation.sources')}
|
||||
</Link>
|
||||
<Typography color="text.primary" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<BlockIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||
Ignored Files
|
||||
{t('ignoredFiles.title')}
|
||||
{(sourceTypeParam || sourceNameParam) && (
|
||||
<Chip
|
||||
label={sourceNameParam ? `${sourceNameParam}` : `${getSourceTypeDisplay(sourceTypeParam)} Sources`}
|
||||
|
|
@ -400,7 +402,7 @@ const IgnoredFilesPage: React.FC = () => {
|
|||
|
||||
<Typography variant="h4" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
<BlockIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Ignored Files
|
||||
{t('ignoredFiles.title')}
|
||||
{(sourceTypeParam || sourceNameParam || sourceIdParam) && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
|
@ -409,16 +411,13 @@ const IgnoredFilesPage: React.FC = () => {
|
|||
onClick={clearFilters}
|
||||
sx={{ ml: 2, textTransform: 'none' }}
|
||||
>
|
||||
View All
|
||||
{t('common.actions.viewDetails')}
|
||||
</Button>
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{sourceTypeParam || sourceNameParam || sourceIdParam
|
||||
? `Files from ${sourceNameParam || getSourceTypeDisplay(sourceTypeParam)} sources that have been deleted and will be ignored during future syncs.`
|
||||
: 'Files that have been deleted and will be ignored during future syncs from their sources.'
|
||||
}
|
||||
{t('ignoredFiles.subtitle')}
|
||||
</Typography>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
|
|
@ -466,7 +465,7 @@ const IgnoredFilesPage: React.FC = () => {
|
|||
<CardContent>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="center">
|
||||
<TextField
|
||||
placeholder="Search filenames..."
|
||||
placeholder={t('ignoredFiles.filters.searchPlaceholder')}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
value={searchTerm}
|
||||
|
|
@ -480,15 +479,15 @@ const IgnoredFilesPage: React.FC = () => {
|
|||
}}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Source Type</InputLabel>
|
||||
<InputLabel>{t('ignoredFiles.filters.reason')}</InputLabel>
|
||||
<Select
|
||||
value={sourceTypeFilter}
|
||||
label="Source Type"
|
||||
label={t('ignoredFiles.filters.reason')}
|
||||
onChange={handleSourceTypeFilter}
|
||||
>
|
||||
<MenuItem value="">All Sources</MenuItem>
|
||||
<MenuItem value="">{t('ignoredFiles.filters.allReasons')}</MenuItem>
|
||||
{uniqueSourceTypes.map(sourceType => (
|
||||
<MenuItem key={sourceType} value={sourceType}>
|
||||
{getSourceTypeDisplay(sourceType)}
|
||||
|
|
@ -505,7 +504,7 @@ const IgnoredFilesPage: React.FC = () => {
|
|||
fetchStats();
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
{t('common.actions.refresh')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
|
|
@ -546,12 +545,12 @@ const IgnoredFilesPage: React.FC = () => {
|
|||
onChange={handleSelectAll}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>Filename</TableCell>
|
||||
<TableCell>{t('ignoredFiles.table.filename')}</TableCell>
|
||||
<TableCell>Source</TableCell>
|
||||
<TableCell>Size</TableCell>
|
||||
<TableCell>Ignored Date</TableCell>
|
||||
<TableCell>Reason</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
<TableCell>{t('ignoredFiles.table.size')}</TableCell>
|
||||
<TableCell>{t('ignoredFiles.table.ignoredAt')}</TableCell>
|
||||
<TableCell>{t('ignoredFiles.table.reason')}</TableCell>
|
||||
<TableCell>{t('common.actions.edit')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
|
@ -571,7 +570,7 @@ const IgnoredFilesPage: React.FC = () => {
|
|||
<TableRow>
|
||||
<TableCell colSpan={7} align="center">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No ignored files found
|
||||
{t('ignoredFiles.empty.subtitle')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
|
|
@ -35,6 +36,7 @@ import { useApi } from '../hooks/useApi';
|
|||
import { ErrorHelper, ErrorCodes } from '../services/api';
|
||||
|
||||
const LabelsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const api = useApi();
|
||||
|
||||
|
|
@ -201,7 +203,7 @@ const LabelsPage: React.FC = () => {
|
|||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Typography>Loading labels...</Typography>
|
||||
<Typography>{t('labels.loading')}</Typography>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
@ -211,14 +213,14 @@ const LabelsPage: React.FC = () => {
|
|||
{/* Header */}
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={4}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Label Management
|
||||
{t('labels.title')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
Create Label
|
||||
{t('labels.actions.createLabel')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
|
|
@ -235,7 +237,7 @@ const LabelsPage: React.FC = () => {
|
|||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search labels..."
|
||||
placeholder={t('labels.search.placeholder')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
InputProps={{
|
||||
|
|
@ -250,7 +252,7 @@ const LabelsPage: React.FC = () => {
|
|||
<Grid item xs={12} md={6}>
|
||||
<Box display="flex" gap={1} flexWrap="wrap">
|
||||
<Chip
|
||||
label="System Labels"
|
||||
label={t('labels.filters.systemLabels')}
|
||||
color={showSystemLabels ? 'primary' : 'default'}
|
||||
onClick={() => setShowSystemLabels(!showSystemLabels)}
|
||||
variant={showSystemLabels ? 'filled' : 'outlined'}
|
||||
|
|
@ -266,7 +268,7 @@ const LabelsPage: React.FC = () => {
|
|||
{systemLabels.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<Typography variant="h6" gutterBottom color="text.secondary">
|
||||
System Labels
|
||||
{t('labels.sections.systemLabels')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{systemLabels.map((label) => (
|
||||
|
|
@ -276,7 +278,7 @@ const LabelsPage: React.FC = () => {
|
|||
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={2}>
|
||||
<Label label={label} showCount />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
System
|
||||
{t('labels.badge.system')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{label.description && (
|
||||
|
|
@ -286,10 +288,10 @@ const LabelsPage: React.FC = () => {
|
|||
)}
|
||||
<Box mt={2} display="flex" gap={2}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Documents: {label.document_count || 0}
|
||||
{t('labels.stats.documents', { count: label.document_count || 0 })}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Sources: {label.source_count || 0}
|
||||
{t('labels.stats.sources', { count: label.source_count || 0 })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
|
@ -304,7 +306,7 @@ const LabelsPage: React.FC = () => {
|
|||
{userLabels.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
My Labels
|
||||
{t('labels.sections.myLabels')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{userLabels.map((label) => (
|
||||
|
|
@ -314,7 +316,7 @@ const LabelsPage: React.FC = () => {
|
|||
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={2}>
|
||||
<Label label={label} showCount />
|
||||
<Box>
|
||||
<Tooltip title="Edit label">
|
||||
<Tooltip title={t('labels.actions.editLabel')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => openEditDialog(label)}
|
||||
|
|
@ -322,7 +324,7 @@ const LabelsPage: React.FC = () => {
|
|||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete label">
|
||||
<Tooltip title={t('labels.actions.deleteLabel')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => openDeleteDialog(label)}
|
||||
|
|
@ -340,10 +342,10 @@ const LabelsPage: React.FC = () => {
|
|||
)}
|
||||
<Box mt={2} display="flex" gap={2}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Documents: {label.document_count || 0}
|
||||
{t('labels.stats.documents', { count: label.document_count || 0 })}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Sources: {label.source_count || 0}
|
||||
{t('labels.stats.sources', { count: label.source_count || 0 })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
|
@ -358,12 +360,12 @@ const LabelsPage: React.FC = () => {
|
|||
{filteredLabels.length === 0 && (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No labels found
|
||||
{t('labels.empty.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={3}>
|
||||
{searchTerm
|
||||
? `No labels match "${searchTerm}"`
|
||||
: "You haven't created any labels yet"
|
||||
{searchTerm
|
||||
? t('labels.empty.noMatch', { query: searchTerm })
|
||||
: t('labels.empty.noLabels')
|
||||
}
|
||||
</Typography>
|
||||
{!searchTerm && (
|
||||
|
|
@ -372,7 +374,7 @@ const LabelsPage: React.FC = () => {
|
|||
startIcon={<AddIcon />}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
Create Your First Label
|
||||
{t('labels.empty.createFirst')}
|
||||
</Button>
|
||||
)}
|
||||
</Paper>
|
||||
|
|
@ -398,12 +400,12 @@ const LabelsPage: React.FC = () => {
|
|||
setLabelToDelete(null);
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Delete Label</DialogTitle>
|
||||
<DialogTitle>{t('labels.dialogs.delete.title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete the label "{labelToDelete?.name}"?
|
||||
{t('labels.dialogs.delete.message', { name: labelToDelete?.name })}
|
||||
{(labelToDelete?.document_count || 0) > 0 && (
|
||||
<> This label is currently used by {labelToDelete?.document_count} document(s).</>
|
||||
<>{t('labels.dialogs.delete.inUseWarning', { count: labelToDelete?.document_count })}</>
|
||||
)}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
|
|
@ -412,14 +414,14 @@ const LabelsPage: React.FC = () => {
|
|||
setDeleteDialogOpen(false);
|
||||
setLabelToDelete(null);
|
||||
}}>
|
||||
Cancel
|
||||
{t('common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => labelToDelete && handleDeleteLabel(labelToDelete.id)}
|
||||
color="error"
|
||||
variant="contained"
|
||||
>
|
||||
Delete
|
||||
{t('common.actions.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
|
|
@ -140,6 +141,7 @@ interface SnippetSettings {
|
|||
}
|
||||
|
||||
const SearchPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [searchQuery, setSearchQuery] = useState<string>(searchParams.get('q') || '');
|
||||
|
|
@ -153,12 +155,12 @@ const SearchPage: React.FC = () => {
|
|||
const [searchProgress, setSearchProgress] = useState<number>(0);
|
||||
const [quickSuggestions, setQuickSuggestions] = useState<string[]>([]);
|
||||
const [showFilters, setShowFilters] = useState<boolean>(false);
|
||||
const [searchTips] = useState<string[]>([
|
||||
'Use quotes for exact phrases: "project plan"',
|
||||
'Search by tags: tag:important or tag:invoice',
|
||||
'Combine terms: contract AND payment',
|
||||
'Use wildcards: proj* for project, projects, etc.'
|
||||
]);
|
||||
const searchTips = [
|
||||
t('search.tips.exactPhrase'),
|
||||
t('search.tips.tags'),
|
||||
t('search.tips.combine'),
|
||||
t('search.tips.wildcards')
|
||||
];
|
||||
|
||||
// Search settings - consolidated into advanced settings
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
|
||||
|
|
@ -551,7 +553,7 @@ const SearchPage: React.FC = () => {
|
|||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Search Documents
|
||||
{t('search.title')}
|
||||
</Typography>
|
||||
|
||||
{/* Enhanced Search Bar */}
|
||||
|
|
@ -569,7 +571,7 @@ const SearchPage: React.FC = () => {
|
|||
<Box sx={{ position: 'relative' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search documents by content, filename, or tags... Try 'invoice', 'contract', or tag:important"
|
||||
placeholder={t('search.placeholder')}
|
||||
variant="outlined"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
|
|
@ -597,7 +599,7 @@ const SearchPage: React.FC = () => {
|
|||
<ClearIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Tooltip title="Search Settings">
|
||||
<Tooltip title={t('search.settings.title')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
|
|
@ -666,9 +668,9 @@ const SearchPage: React.FC = () => {
|
|||
gap: 2,
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>
|
||||
<Chip
|
||||
<Chip
|
||||
icon={<TrendingIcon />}
|
||||
label={`${totalResults} results`}
|
||||
label={t('search.status.resultsFound', { count: totalResults })}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
|
|
@ -682,9 +684,9 @@ const SearchPage: React.FC = () => {
|
|||
sx={{ flexShrink: 0 }}
|
||||
/>
|
||||
{advancedSettings.useEnhancedSearch && (
|
||||
<Chip
|
||||
<Chip
|
||||
icon={<SpeedIcon />}
|
||||
label="Enhanced"
|
||||
label={t('search.modes.enhanced')}
|
||||
size="small"
|
||||
color="success"
|
||||
variant="outlined"
|
||||
|
|
@ -701,10 +703,10 @@ const SearchPage: React.FC = () => {
|
|||
onChange={handleSearchModeChange}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="simple">Smart</ToggleButton>
|
||||
<ToggleButton value="phrase">Exact phrase</ToggleButton>
|
||||
<ToggleButton value="fuzzy">Similar words</ToggleButton>
|
||||
<ToggleButton value="boolean">Advanced</ToggleButton>
|
||||
<ToggleButton value="simple">{t('search.modes.smart')}</ToggleButton>
|
||||
<ToggleButton value="phrase">{t('search.modes.exactPhrase')}</ToggleButton>
|
||||
<ToggleButton value="fuzzy">{t('search.modes.similarWords')}</ToggleButton>
|
||||
<ToggleButton value="boolean">{t('search.modes.advanced')}</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -714,7 +716,7 @@ const SearchPage: React.FC = () => {
|
|||
{quickSuggestions.length > 0 && searchQuery && !loading && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Quick suggestions:
|
||||
{t('search.quickSuggestions.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{quickSuggestions.map((suggestion, index) => (
|
||||
|
|
@ -743,7 +745,7 @@ const SearchPage: React.FC = () => {
|
|||
{suggestions.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Related searches:
|
||||
{t('search.relatedSearches.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
|
|
@ -798,10 +800,10 @@ const SearchPage: React.FC = () => {
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FilterIcon />
|
||||
Filters
|
||||
{t('search.filters.title')}
|
||||
</Typography>
|
||||
<Button size="small" onClick={handleClearFilters} startIcon={<ClearIcon />}>
|
||||
Clear
|
||||
{t('common.actions.clear')}
|
||||
</Button>
|
||||
</Box>
|
||||
{/* Mobile filter content would go here - simplified */}
|
||||
|
|
@ -820,10 +822,10 @@ const SearchPage: React.FC = () => {
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FilterIcon />
|
||||
Filters
|
||||
{t('search.filters.title')}
|
||||
</Typography>
|
||||
<Button size="small" onClick={handleClearFilters} startIcon={<ClearIcon />}>
|
||||
Clear
|
||||
{t('common.actions.clear')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
|
|
@ -831,16 +833,16 @@ const SearchPage: React.FC = () => {
|
|||
{/* Tags Filter */}
|
||||
<Accordion defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle2">Tags</Typography>
|
||||
<Typography variant="subtitle2">{t('search.filters.tags')}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Select Tags</InputLabel>
|
||||
<InputLabel>{t('search.filters.selectTags')}</InputLabel>
|
||||
<Select<string[]>
|
||||
multiple
|
||||
value={selectedTags}
|
||||
onChange={handleTagsChange}
|
||||
input={<OutlinedInput label="Select Tags" />}
|
||||
input={<OutlinedInput label={t('search.filters.selectTags')} />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, overflow: 'hidden' }}>
|
||||
{selected.map((value) => (
|
||||
|
|
@ -885,19 +887,19 @@ const SearchPage: React.FC = () => {
|
|||
{/* OCR Filter */}
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle2">OCR Status</Typography>
|
||||
<Typography variant="subtitle2">{t('search.filters.ocrStatus')}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>OCR Text</InputLabel>
|
||||
<InputLabel>{t('search.filters.ocrText')}</InputLabel>
|
||||
<Select
|
||||
value={hasOcr}
|
||||
onChange={handleOcrChange}
|
||||
label="OCR Text"
|
||||
label={t('search.filters.ocrText')}
|
||||
>
|
||||
<MenuItem value="all">All Documents</MenuItem>
|
||||
<MenuItem value="yes">Has OCR Text</MenuItem>
|
||||
<MenuItem value="no">No OCR Text</MenuItem>
|
||||
<MenuItem value="all">{t('search.filters.allDocuments')}</MenuItem>
|
||||
<MenuItem value="yes">{t('search.filters.hasOcrText')}</MenuItem>
|
||||
<MenuItem value="no">{t('search.filters.noOcrText')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</AccordionDetails>
|
||||
|
|
@ -906,11 +908,11 @@ const SearchPage: React.FC = () => {
|
|||
{/* Date Range Filter */}
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle2">Date Range</Typography>
|
||||
<Typography variant="subtitle2">{t('search.filters.dateRange')}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Days ago: {dateRange[0]} - {dateRange[1]}
|
||||
{t('search.filters.daysAgo', { min: dateRange[0], max: dateRange[1] })}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={dateRange}
|
||||
|
|
@ -919,10 +921,10 @@ const SearchPage: React.FC = () => {
|
|||
min={0}
|
||||
max={365}
|
||||
marks={[
|
||||
{ value: 0, label: 'Today' },
|
||||
{ value: 30, label: '30d' },
|
||||
{ value: 90, label: '90d' },
|
||||
{ value: 365, label: '1y' },
|
||||
{ value: 0, label: t('search.filters.dateMarks.today') },
|
||||
{ value: 30, label: t('search.filters.dateMarks.30d') },
|
||||
{ value: 90, label: t('search.filters.dateMarks.90d') },
|
||||
{ value: 365, label: t('search.filters.dateMarks.1y') },
|
||||
]}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
|
|
@ -931,11 +933,11 @@ const SearchPage: React.FC = () => {
|
|||
{/* File Size Filter */}
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle2">File Size</Typography>
|
||||
<Typography variant="subtitle2">{t('search.filters.fileSize')}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Size: {fileSizeRange[0]}MB - {fileSizeRange[1]}MB
|
||||
{t('search.filters.sizeRange', { min: fileSizeRange[0], max: fileSizeRange[1] })}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={fileSizeRange}
|
||||
|
|
@ -965,7 +967,7 @@ const SearchPage: React.FC = () => {
|
|||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2, mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{loading ? 'Searching...' : `${searchResults.length} results found`}
|
||||
{loading ? t('search.status.searching') : t('search.status.resultsFound', { count: searchResults.length })}
|
||||
</Typography>
|
||||
|
||||
{/* Snippet Settings Button */}
|
||||
|
|
@ -974,12 +976,12 @@ const SearchPage: React.FC = () => {
|
|||
size="small"
|
||||
startIcon={<TextFormatIcon />}
|
||||
onClick={(e) => setSnippetSettingsAnchor(e.currentTarget)}
|
||||
sx={{
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
Display Settings
|
||||
{t('search.display.settings')}
|
||||
{/* Show indicator if settings are customized */}
|
||||
{(snippetSettings.viewMode !== 'detailed' ||
|
||||
snippetSettings.highlightStyle !== 'background' ||
|
||||
|
|
@ -1004,16 +1006,16 @@ const SearchPage: React.FC = () => {
|
|||
{!loading && searchResults.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Showing:
|
||||
{t('search.results.showing')}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${snippetSettings.maxSnippetsToShow} snippets`}
|
||||
<Chip
|
||||
label={t('search.results.snippetsCount', { count: snippetSettings.maxSnippetsToShow })}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.7rem' }}
|
||||
/>
|
||||
<Chip
|
||||
label={`${snippetSettings.fontSize}px font`}
|
||||
<Chip
|
||||
label={t('search.results.fontSize', { size: snippetSettings.fontSize })}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.7rem' }}
|
||||
|
|
@ -1054,40 +1056,40 @@ const SearchPage: React.FC = () => {
|
|||
}}
|
||||
>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No results found for "{searchQuery}"
|
||||
{t('search.noResults.title', { query: searchQuery })}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Try adjusting your search terms or filters
|
||||
{t('search.noResults.subtitle')}
|
||||
</Typography>
|
||||
|
||||
|
||||
{/* Helpful suggestions for no results */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" color="text.primary" gutterBottom>
|
||||
Suggestions:
|
||||
{t('search.noResults.suggestions.title')}
|
||||
</Typography>
|
||||
<Stack spacing={1} alignItems="center">
|
||||
<Typography variant="body2" color="text.secondary">• Try simpler or more general terms</Typography>
|
||||
<Typography variant="body2" color="text.secondary">• Check spelling and try different keywords</Typography>
|
||||
<Typography variant="body2" color="text.secondary">• Remove some filters to broaden your search</Typography>
|
||||
<Typography variant="body2" color="text.secondary">• Use quotes for exact phrases</Typography>
|
||||
<Typography variant="body2" color="text.secondary">• {t('search.noResults.suggestions.simpler')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">• {t('search.noResults.suggestions.spelling')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">• {t('search.noResults.suggestions.removeFilters')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">• {t('search.noResults.suggestions.useQuotes')}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={handleClearFilters}
|
||||
startIcon={<ClearIcon />}
|
||||
>
|
||||
Clear Filters
|
||||
{t('search.actions.clearFilters')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
New Search
|
||||
{t('search.actions.newSearch')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
|
@ -1104,16 +1106,16 @@ const SearchPage: React.FC = () => {
|
|||
>
|
||||
<SearchIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Start searching your documents
|
||||
{t('search.empty.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Use the enhanced search bar above to find documents by content, filename, or tags
|
||||
{t('search.empty.subtitle')}
|
||||
</Typography>
|
||||
|
||||
|
||||
{/* Search Tips */}
|
||||
<Box sx={{ mb: 3, maxWidth: 600, mx: 'auto' }}>
|
||||
<Typography variant="subtitle2" color="text.primary" gutterBottom>
|
||||
Search Tips:
|
||||
{t('search.tips.title')}
|
||||
</Typography>
|
||||
<Stack spacing={1} alignItems="center">
|
||||
{searchTips.map((tip, index) => (
|
||||
|
|
@ -1125,8 +1127,8 @@ const SearchPage: React.FC = () => {
|
|||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
|
||||
<Chip
|
||||
label="Try: invoice"
|
||||
<Chip
|
||||
label={t('search.examples.invoice')}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
|
|
@ -1135,8 +1137,8 @@ const SearchPage: React.FC = () => {
|
|||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Try: contract"
|
||||
<Chip
|
||||
label={t('search.examples.contract')}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
|
|
@ -1145,8 +1147,8 @@ const SearchPage: React.FC = () => {
|
|||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Try: tag:important"
|
||||
<Chip
|
||||
label={t('search.examples.tagImportant')}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
|
|
@ -1228,7 +1230,7 @@ const SearchPage: React.FC = () => {
|
|||
}}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mr: 1 }}>
|
||||
{formatFileSize(doc.file_size)} • {formatDate(doc.created_at)}
|
||||
{doc.has_ocr_text && ' • OCR'}
|
||||
{doc.has_ocr_text && t('search.results.hasOcr')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
|
@ -1242,7 +1244,7 @@ const SearchPage: React.FC = () => {
|
|||
alignItems: 'center',
|
||||
}}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>
|
||||
Tags:
|
||||
{t('search.results.tags')}
|
||||
</Typography>
|
||||
{doc.tags.slice(0, 3).map((tag, index) => (
|
||||
<Chip
|
||||
|
|
@ -1267,7 +1269,7 @@ const SearchPage: React.FC = () => {
|
|||
))}
|
||||
{doc.tags.length > 3 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
+{doc.tags.length - 3} more
|
||||
{t('common.moreCount', { count: doc.tags.length - 3 })}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
|
@ -1307,7 +1309,7 @@ const SearchPage: React.FC = () => {
|
|||
justifyContent: 'flex-start',
|
||||
pt: 0.5,
|
||||
}}>
|
||||
<Tooltip title="View Details">
|
||||
<Tooltip title={t('common.actions.viewDetails')}>
|
||||
<IconButton
|
||||
className="search-filter-button search-focusable"
|
||||
size="small"
|
||||
|
|
@ -1326,7 +1328,7 @@ const SearchPage: React.FC = () => {
|
|||
<ViewIcon sx={{ fontSize: '1.1rem' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Download">
|
||||
<Tooltip title={t('common.actions.download')}>
|
||||
<IconButton
|
||||
className="search-filter-button search-focusable"
|
||||
size="small"
|
||||
|
|
@ -1378,7 +1380,11 @@ const SearchPage: React.FC = () => {
|
|||
{/* Results Summary */}
|
||||
<Box sx={{ textAlign: 'center', mt: 2, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Showing {((currentPage - 1) * resultsPerPage) + 1}-{Math.min(currentPage * resultsPerPage, totalResults)} of {totalResults} results
|
||||
{t('search.results.pagination', {
|
||||
start: ((currentPage - 1) * resultsPerPage) + 1,
|
||||
end: Math.min(currentPage * resultsPerPage, totalResults),
|
||||
total: totalResults
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
|
|
@ -1394,12 +1400,12 @@ const SearchPage: React.FC = () => {
|
|||
PaperProps={{ sx: { width: 320, p: 2 } }}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2 }}>
|
||||
Text Display Settings
|
||||
{t('search.display.textSettings')}
|
||||
</Typography>
|
||||
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom>
|
||||
View Mode
|
||||
{t('search.display.viewMode.label')}
|
||||
</Typography>
|
||||
<RadioGroup
|
||||
value={snippetSettings.viewMode}
|
||||
|
|
@ -1408,17 +1414,17 @@ const SearchPage: React.FC = () => {
|
|||
<FormControlLabel
|
||||
value="compact"
|
||||
control={<Radio size="small" />}
|
||||
label="Compact"
|
||||
label={t('search.display.viewMode.compact')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="detailed"
|
||||
control={<Radio size="small" />}
|
||||
label="Detailed"
|
||||
label={t('search.display.viewMode.detailed')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="context"
|
||||
control={<Radio size="small" />}
|
||||
label="Context Focus"
|
||||
label={t('search.display.viewMode.contextFocus')}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
|
@ -1427,7 +1433,7 @@ const SearchPage: React.FC = () => {
|
|||
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom>
|
||||
Highlight Style
|
||||
{t('search.display.highlightStyle.label')}
|
||||
</Typography>
|
||||
<RadioGroup
|
||||
value={snippetSettings.highlightStyle}
|
||||
|
|
@ -1436,17 +1442,17 @@ const SearchPage: React.FC = () => {
|
|||
<FormControlLabel
|
||||
value="background"
|
||||
control={<Radio size="small" />}
|
||||
label="Background Color"
|
||||
label={t('search.display.highlightStyle.background')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="underline"
|
||||
control={<Radio size="small" />}
|
||||
label="Underline"
|
||||
label={t('search.display.highlightStyle.underline')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="bold"
|
||||
control={<Radio size="small" />}
|
||||
label="Bold Text"
|
||||
label={t('search.display.highlightStyle.bold')}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
|
@ -1455,7 +1461,7 @@ const SearchPage: React.FC = () => {
|
|||
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom>
|
||||
Font Size: {snippetSettings.fontSize}px
|
||||
{t('search.display.fontSizeLabel', { size: snippetSettings.fontSize })}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={snippetSettings.fontSize}
|
||||
|
|
@ -1471,7 +1477,7 @@ const SearchPage: React.FC = () => {
|
|||
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom>
|
||||
Snippets per result: {snippetSettings.maxSnippetsToShow}
|
||||
{t('search.display.snippetsPerResult', { count: snippetSettings.maxSnippetsToShow })}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={snippetSettings.maxSnippetsToShow}
|
||||
|
|
@ -1488,7 +1494,7 @@ const SearchPage: React.FC = () => {
|
|||
<Divider sx={{ my: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom>
|
||||
Context Length: {snippetSettings.contextLength} characters
|
||||
{t('search.display.contextLength', { length: snippetSettings.contextLength })}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={snippetSettings.contextLength}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -79,6 +79,7 @@ import { useNavigate } from 'react-router-dom';
|
|||
import api, { queueService, sourcesService, ErrorHelper, ErrorCodes } from '../services/api';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SyncProgressDisplay from '../components/SyncProgress';
|
||||
|
||||
interface Source {
|
||||
|
|
@ -115,6 +116,7 @@ const SourcesPage: React.FC = () => {
|
|||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [ocrStatus, setOcrStatus] = useState<{ is_paused: boolean; status: string }>({ is_paused: false, status: 'running' });
|
||||
|
|
@ -226,7 +228,7 @@ const SourcesPage: React.FC = () => {
|
|||
setSources(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load sources:', error);
|
||||
showSnackbar('Failed to load sources', 'error');
|
||||
showSnackbar(t('sources.errors.loadFailed'), 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -253,10 +255,10 @@ const SourcesPage: React.FC = () => {
|
|||
try {
|
||||
await queueService.pauseOcr();
|
||||
await loadOcrStatus();
|
||||
showSnackbar('OCR processing paused successfully', 'success');
|
||||
showSnackbar(t('sources.ocr.pausedSuccess'), 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to pause OCR:', error);
|
||||
showSnackbar('Failed to pause OCR processing', 'error');
|
||||
showSnackbar(t('sources.ocr.pauseFailed'), 'error');
|
||||
} finally {
|
||||
setOcrLoading(false);
|
||||
}
|
||||
|
|
@ -268,10 +270,10 @@ const SourcesPage: React.FC = () => {
|
|||
try {
|
||||
await queueService.resumeOcr();
|
||||
await loadOcrStatus();
|
||||
showSnackbar('OCR processing resumed successfully', 'success');
|
||||
showSnackbar(t('sources.ocr.resumedSuccess'), 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to resume OCR:', error);
|
||||
showSnackbar('Failed to resume OCR processing', 'error');
|
||||
showSnackbar(t('sources.ocr.resumeFailed'), 'error');
|
||||
} finally {
|
||||
setOcrLoading(false);
|
||||
}
|
||||
|
|
@ -390,7 +392,7 @@ const SourcesPage: React.FC = () => {
|
|||
enabled: formData.enabled,
|
||||
config,
|
||||
});
|
||||
showSnackbar('Source updated successfully', 'success');
|
||||
showSnackbar(t('sources.messages.updateSuccess'), 'success');
|
||||
} else {
|
||||
await api.post('/sources', {
|
||||
name: formData.name,
|
||||
|
|
@ -398,7 +400,7 @@ const SourcesPage: React.FC = () => {
|
|||
enabled: formData.enabled,
|
||||
config,
|
||||
});
|
||||
showSnackbar('Source created successfully', 'success');
|
||||
showSnackbar(t('sources.messages.createSuccess'), 'success');
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
|
|
@ -410,17 +412,17 @@ const SourcesPage: React.FC = () => {
|
|||
|
||||
// Handle specific source errors
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_DUPLICATE_NAME)) {
|
||||
showSnackbar('A source with this name already exists. Please choose a different name.', 'error');
|
||||
showSnackbar(t('sources.errors.duplicateName'), 'error');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONFIG_INVALID)) {
|
||||
showSnackbar('Source configuration is invalid. Please check your settings and try again.', 'error');
|
||||
showSnackbar(t('sources.errors.invalidConfig'), 'error');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_AUTH_FAILED)) {
|
||||
showSnackbar('Authentication failed. Please verify your credentials.', 'error');
|
||||
showSnackbar(t('sources.errors.authFailed'), 'error');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONNECTION_FAILED)) {
|
||||
showSnackbar('Cannot connect to the source. Please check your network and server settings.', 'error');
|
||||
showSnackbar(t('sources.errors.connectionError'), 'error');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_INVALID_PATH)) {
|
||||
showSnackbar('Invalid path specified. Please check your folder paths and try again.', 'error');
|
||||
showSnackbar(t('sources.errors.invalidPath'), 'error');
|
||||
} else {
|
||||
showSnackbar(errorInfo.message || 'Failed to save source', 'error');
|
||||
showSnackbar(errorInfo.message || t('sources.errors.saveFailed'), 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -442,7 +444,7 @@ const SourcesPage: React.FC = () => {
|
|||
setDeleteLoading(true);
|
||||
try {
|
||||
await api.delete(`/sources/${sourceToDelete.id}`);
|
||||
showSnackbar('Source deleted successfully', 'success');
|
||||
showSnackbar(t('sources.messages.deleteSuccess'), 'success');
|
||||
loadSources();
|
||||
handleDeleteCancel();
|
||||
} catch (error) {
|
||||
|
|
@ -452,13 +454,13 @@ const SourcesPage: React.FC = () => {
|
|||
|
||||
// Handle specific delete errors
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_NOT_FOUND)) {
|
||||
showSnackbar('Source not found. It may have already been deleted.', 'warning');
|
||||
showSnackbar(t('sources.errors.notFound'), 'warning');
|
||||
loadSources(); // Refresh the list
|
||||
handleDeleteCancel();
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_SYNC_IN_PROGRESS)) {
|
||||
showSnackbar('Cannot delete source while sync is in progress. Please stop the sync first.', 'error');
|
||||
showSnackbar(t('sources.errors.syncInProgress'), 'error');
|
||||
} else {
|
||||
showSnackbar(errorInfo.message || 'Failed to delete source', 'error');
|
||||
showSnackbar(errorInfo.message || t('sources.errors.deleteFailed'), 'error');
|
||||
}
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
|
|
@ -505,9 +507,9 @@ const SourcesPage: React.FC = () => {
|
|||
}
|
||||
|
||||
if (response && response.data.success) {
|
||||
showSnackbar(response.data.message || 'Connection successful!', 'success');
|
||||
showSnackbar(response.data.message || t('sources.messages.connectionSuccess'), 'success');
|
||||
} else {
|
||||
showSnackbar(response?.data.message || 'Connection failed', 'error');
|
||||
showSnackbar(response?.data.message || t('sources.errors.connectionFailed'), 'error');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to test connection:', error);
|
||||
|
|
@ -516,17 +518,17 @@ const SourcesPage: React.FC = () => {
|
|||
|
||||
// Handle specific connection test errors
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONNECTION_FAILED)) {
|
||||
showSnackbar('Connection failed. Please check your server URL and network connectivity.', 'error');
|
||||
showSnackbar(t('sources.errors.connectionFailedUrl'), 'error');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_AUTH_FAILED)) {
|
||||
showSnackbar('Authentication failed. Please verify your username and password.', 'error');
|
||||
showSnackbar(t('sources.errors.authFailedCredentials'), 'error');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_INVALID_PATH)) {
|
||||
showSnackbar('Invalid path specified. Please check your folder paths.', 'error');
|
||||
showSnackbar(t('sources.errors.invalidFolderPath'), 'error');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONFIG_INVALID)) {
|
||||
showSnackbar('Configuration is invalid. Please review your settings.', 'error');
|
||||
showSnackbar(t('sources.errors.invalidSettings'), 'error');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_NETWORK_TIMEOUT)) {
|
||||
showSnackbar('Connection timed out. Please check your network and try again.', 'error');
|
||||
showSnackbar(t('sources.errors.timeout'), 'error');
|
||||
} else {
|
||||
showSnackbar(errorInfo.message || 'Failed to test connection', 'error');
|
||||
showSnackbar(errorInfo.message || t('sources.errors.testConnectionFailed'), 'error');
|
||||
}
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
|
|
@ -552,7 +554,7 @@ const SourcesPage: React.FC = () => {
|
|||
|
||||
try {
|
||||
await sourcesService.triggerSync(sourceToSync.id);
|
||||
showSnackbar('Quick sync started successfully', 'success');
|
||||
showSnackbar(t('sources.messages.syncStartSuccess'), 'success');
|
||||
setTimeout(loadSources, 1000);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to trigger sync:', error);
|
||||
|
|
@ -561,16 +563,16 @@ const SourcesPage: React.FC = () => {
|
|||
|
||||
// Handle specific sync errors
|
||||
if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_SYNC_IN_PROGRESS)) {
|
||||
showSnackbar('Source is already syncing. Please wait for the current sync to complete.', 'warning');
|
||||
showSnackbar(t('sources.errors.alreadySyncing'), 'warning');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONNECTION_FAILED)) {
|
||||
showSnackbar('Cannot connect to source. Please check your connection and try again.', 'error');
|
||||
showSnackbar(t('sources.errors.cannotConnect'), 'error');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_AUTH_FAILED)) {
|
||||
showSnackbar('Authentication failed. Please verify your source credentials.', 'error');
|
||||
showSnackbar(t('sources.errors.authFailedSource'), 'error');
|
||||
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_NOT_FOUND)) {
|
||||
showSnackbar('Source not found. It may have been deleted.', 'error');
|
||||
showSnackbar(t('sources.errors.sourceDeleted'), 'error');
|
||||
loadSources(); // Refresh the sources list
|
||||
} else {
|
||||
showSnackbar(errorInfo.message || 'Failed to start sync', 'error');
|
||||
showSnackbar(errorInfo.message || t('sources.errors.syncStartFailed'), 'error');
|
||||
}
|
||||
} finally {
|
||||
setSyncingSource(null);
|
||||
|
|
@ -585,16 +587,16 @@ const SourcesPage: React.FC = () => {
|
|||
|
||||
try {
|
||||
await sourcesService.triggerDeepScan(sourceToSync.id);
|
||||
showSnackbar('Deep scan started successfully', 'success');
|
||||
showSnackbar(t('sources.messages.deepScanSuccess'), 'success');
|
||||
setTimeout(loadSources, 1000);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to trigger deep scan:', error);
|
||||
if (error.response?.status === 409) {
|
||||
showSnackbar('Source is already syncing', 'warning');
|
||||
} else if (error.response?.status === 400 && error.response?.data?.message?.includes('only supported for WebDAV')) {
|
||||
showSnackbar('Deep scan is only supported for WebDAV sources', 'warning');
|
||||
showSnackbar(t('sources.errors.deepScanWebdavOnly'), 'warning');
|
||||
} else {
|
||||
showSnackbar('Failed to start deep scan', 'error');
|
||||
showSnackbar(t('sources.errors.deepScanFailed'), 'error');
|
||||
}
|
||||
} finally {
|
||||
setDeepScanning(false);
|
||||
|
|
@ -605,14 +607,14 @@ const SourcesPage: React.FC = () => {
|
|||
setStoppingSync(sourceId);
|
||||
try {
|
||||
await sourcesService.stopSync(sourceId);
|
||||
showSnackbar('Sync stopped successfully', 'success');
|
||||
showSnackbar(t('sources.messages.syncStopSuccess'), 'success');
|
||||
setTimeout(loadSources, 1000);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to stop sync:', error);
|
||||
if (error.response?.status === 409) {
|
||||
showSnackbar('Source is not currently syncing', 'warning');
|
||||
showSnackbar(t('sources.errors.notSyncing'), 'warning');
|
||||
} else {
|
||||
showSnackbar('Failed to stop sync', 'error');
|
||||
showSnackbar(t('sources.errors.syncStopFailed'), 'error');
|
||||
}
|
||||
} finally {
|
||||
setStoppingSync(null);
|
||||
|
|
@ -646,29 +648,29 @@ const SourcesPage: React.FC = () => {
|
|||
|
||||
let statusColor = theme.palette.grey[500];
|
||||
let StatusIcon = HealthIcon;
|
||||
let statusText = 'Unknown';
|
||||
let tooltipText = 'Validation status unknown';
|
||||
let statusText = t('sources.validation.unknown');
|
||||
let tooltipText = t('sources.validation.statusUnknown');
|
||||
|
||||
if (validationStatus === 'healthy') {
|
||||
statusColor = theme.palette.success.main;
|
||||
StatusIcon = CheckCircleIcon;
|
||||
statusText = 'Healthy';
|
||||
tooltipText = `Health score: ${validationScore || 'N/A'}`;
|
||||
statusText = t('sources.validation.healthy');
|
||||
tooltipText = `t('sources.validation.healthScore', { score: validationScore || t('sources.labels.notAvailable') })`;
|
||||
} else if (validationStatus === 'warning') {
|
||||
statusColor = theme.palette.warning.main;
|
||||
StatusIcon = WarningIcon;
|
||||
statusText = 'Warning';
|
||||
tooltipText = `Health score: ${validationScore || 'N/A'} - Issues detected`;
|
||||
statusText = t('sources.validation.warning');
|
||||
tooltipText = `t('sources.validation.healthScore', { score: validationScore || t('sources.labels.notAvailable') }) - Issues detected`;
|
||||
} else if (validationStatus === 'critical') {
|
||||
statusColor = theme.palette.error.main;
|
||||
StatusIcon = CriticalIcon;
|
||||
statusText = 'Critical';
|
||||
tooltipText = `Health score: ${validationScore || 'N/A'} - Critical issues`;
|
||||
statusText = t('sources.validation.critical');
|
||||
tooltipText = `t('sources.validation.healthScore', { score: validationScore || t('sources.labels.notAvailable') }) - Critical issues`;
|
||||
} else if (validationStatus === 'validating') {
|
||||
statusColor = theme.palette.info.main;
|
||||
StatusIcon = HealthIcon;
|
||||
statusText = 'Validating';
|
||||
tooltipText = 'Validation check in progress';
|
||||
statusText = t('sources.validation.validating');
|
||||
tooltipText = t('sources.validation.inProgress');
|
||||
}
|
||||
|
||||
if (lastValidationAt) {
|
||||
|
|
@ -754,10 +756,10 @@ const SourcesPage: React.FC = () => {
|
|||
});
|
||||
}
|
||||
setCrawlEstimate(response.data);
|
||||
showSnackbar('Crawl estimation completed', 'success');
|
||||
showSnackbar(t('sources.messages.estimationSuccess'), 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to estimate crawl:', error);
|
||||
showSnackbar('Failed to estimate crawl', 'error');
|
||||
showSnackbar(t('sources.errors.estimateFailed'), 'error');
|
||||
} finally {
|
||||
setEstimatingCrawl(false);
|
||||
}
|
||||
|
|
@ -973,7 +975,7 @@ const SourcesPage: React.FC = () => {
|
|||
/>
|
||||
<Chip
|
||||
icon={<OcrIcon sx={{ fontSize: '0.9rem !important' }} />}
|
||||
label={`${source.total_documents_ocr} OCR'd`}
|
||||
label={t('sources.stats.ocrCount', { count: source.total_documents_ocr })}
|
||||
size="small"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
|
|
@ -1134,7 +1136,7 @@ const SourcesPage: React.FC = () => {
|
|||
label="Last Sync"
|
||||
value={source.last_sync_at
|
||||
? formatDistanceToNow(new Date(source.last_sync_at), { addSuffix: true })
|
||||
: 'Never'}
|
||||
: t('sources.stats.never')}
|
||||
color="primary"
|
||||
tooltip="When this source was last synchronized"
|
||||
/>
|
||||
|
|
@ -1256,7 +1258,7 @@ const SourcesPage: React.FC = () => {
|
|||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}}
|
||||
>
|
||||
{autoRefreshing ? 'Auto-refreshing...' : 'Refresh'}
|
||||
{autoRefreshing ? t('sources.status.autoRefreshing') : t('common.actions.refresh')}
|
||||
</Button>
|
||||
|
||||
{/* OCR Controls for Admin Users */}
|
||||
|
|
@ -1404,10 +1406,10 @@ const SourcesPage: React.FC = () => {
|
|||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
{editingSource ? 'Edit Source' : 'Create New Source'}
|
||||
{editingSource ? t('sources.actions.editSource') : t('sources.dialog.createTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{editingSource ? 'Update your source configuration' : 'Connect a new document source'}
|
||||
{editingSource ? t('sources.dialog.editSubtitle') : t('sources.dialog.createSubtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
|
@ -1767,7 +1769,7 @@ const SourcesPage: React.FC = () => {
|
|||
startIcon={estimatingCrawl ? <CircularProgress size={20} /> : <AssessmentIcon />}
|
||||
sx={{ mb: 2, borderRadius: 2 }}
|
||||
>
|
||||
{estimatingCrawl ? 'Estimating...' : 'Estimate Crawl'}
|
||||
{estimatingCrawl ? t('sources.estimation.estimating') : t('sources.estimation.estimate')}
|
||||
</Button>
|
||||
|
||||
{estimatingCrawl && (
|
||||
|
|
@ -2391,7 +2393,7 @@ const SourcesPage: React.FC = () => {
|
|||
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
|
||||
}}
|
||||
>
|
||||
{editingSource ? 'Save Changes' : 'Create Source'}
|
||||
{editingSource ? 'Save Changes' : t('sources.actions.createSource')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
@ -2426,7 +2428,7 @@ const SourcesPage: React.FC = () => {
|
|||
px: 3,
|
||||
}}
|
||||
>
|
||||
{deleteLoading ? 'Deleting...' : 'Delete'}
|
||||
{deleteLoading ? t('sources.delete.deleting') : t('common.actions.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
|
|
@ -35,37 +36,38 @@ interface UploadedDocument {
|
|||
created_at: string;
|
||||
}
|
||||
|
||||
const features: Feature[] = [
|
||||
{
|
||||
icon: AutoIcon,
|
||||
title: 'AI-Powered OCR',
|
||||
description: 'Advanced text extraction from any document type',
|
||||
},
|
||||
{
|
||||
icon: SearchIcon,
|
||||
title: 'Full-Text Search',
|
||||
description: 'Find documents instantly by content or metadata',
|
||||
},
|
||||
{
|
||||
icon: SpeedIcon,
|
||||
title: 'Lightning Fast',
|
||||
description: 'Process documents in seconds, not minutes',
|
||||
},
|
||||
{
|
||||
icon: SecurityIcon,
|
||||
title: 'Secure & Private',
|
||||
description: 'Your documents are encrypted and protected',
|
||||
},
|
||||
{
|
||||
icon: LanguageIcon,
|
||||
title: 'Multi-Language',
|
||||
description: 'Support for 100+ languages and scripts',
|
||||
},
|
||||
];
|
||||
|
||||
const UploadPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const features: Feature[] = [
|
||||
{
|
||||
icon: AutoIcon,
|
||||
title: t('upload.features.aiOcr.title'),
|
||||
description: t('upload.features.aiOcr.description'),
|
||||
},
|
||||
{
|
||||
icon: SearchIcon,
|
||||
title: t('upload.features.fullTextSearch.title'),
|
||||
description: t('upload.features.fullTextSearch.description'),
|
||||
},
|
||||
{
|
||||
icon: SpeedIcon,
|
||||
title: t('upload.features.lightningFast.title'),
|
||||
description: t('upload.features.lightningFast.description'),
|
||||
},
|
||||
{
|
||||
icon: SecurityIcon,
|
||||
title: t('upload.features.secure.title'),
|
||||
description: t('upload.features.secure.description'),
|
||||
},
|
||||
{
|
||||
icon: LanguageIcon,
|
||||
title: t('upload.features.multiLanguage.title'),
|
||||
description: t('upload.features.multiLanguage.description'),
|
||||
},
|
||||
];
|
||||
|
||||
const handleUploadComplete = (document: UploadedDocument): void => {
|
||||
// Optionally navigate to the document or show a success message
|
||||
console.log('Upload completed:', document);
|
||||
|
|
@ -75,10 +77,10 @@ const UploadPage: React.FC = () => {
|
|||
<Container maxWidth="lg">
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
Upload Documents
|
||||
{t('upload.title')}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Transform your documents with intelligent OCR processing
|
||||
{t('upload.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
|
@ -95,27 +97,27 @@ const UploadPage: React.FC = () => {
|
|||
<Card elevation={0} sx={{ mt: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
📋 Upload Tips
|
||||
{t('upload.tips.title')}
|
||||
</Typography>
|
||||
<List dense sx={{ p: 0 }}>
|
||||
<ListItem sx={{ px: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
• For best OCR results, use high-resolution images
|
||||
{t('upload.tips.highRes')}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
• PDF files with text layers are processed faster
|
||||
{t('upload.tips.pdfText')}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
• Ensure documents are well-lit and clearly readable
|
||||
{t('upload.tips.clarity')}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
• Maximum file size is 50MB per document
|
||||
{t('upload.tips.maxSize')}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
import { useTheme } from '@mui/material/styles';
|
||||
import { queueService, QueueStats, userWatchService, UserWatchDirectoryResponse } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface WatchConfig {
|
||||
watchFolder: string;
|
||||
|
|
@ -49,6 +50,7 @@ interface WatchConfig {
|
|||
}
|
||||
|
||||
const WatchFolderPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { user } = useAuth();
|
||||
|
||||
|
|
@ -200,7 +202,7 @@ const WatchFolderPage: React.FC = () => {
|
|||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Typography variant="h4" sx={{
|
||||
<Typography variant="h4" sx={{
|
||||
fontWeight: 600,
|
||||
background: theme.palette.mode === 'light'
|
||||
? 'linear-gradient(135deg, #1e293b 0%, #6366f1 100%)'
|
||||
|
|
@ -209,7 +211,7 @@ const WatchFolderPage: React.FC = () => {
|
|||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}>
|
||||
Watch Folder
|
||||
{t('watchFolder.title')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
|
@ -223,9 +225,9 @@ const WatchFolderPage: React.FC = () => {
|
|||
disabled={loading || userWatchLoading}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Refresh All
|
||||
{t('watchFolder.refreshAll')}
|
||||
</Button>
|
||||
|
||||
|
||||
{queueStats && queueStats.failed_count > 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
|
|
@ -234,7 +236,7 @@ const WatchFolderPage: React.FC = () => {
|
|||
onClick={requeueFailedJobs}
|
||||
disabled={requeuingFailed || loading}
|
||||
>
|
||||
{requeuingFailed ? 'Requeuing...' : `Retry ${queueStats.failed_count} Failed Jobs`}
|
||||
{requeuingFailed ? t('watchFolder.requeuing') : t('watchFolder.retryFailedJobs', { count: queueStats.failed_count })}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
|
@ -257,11 +259,11 @@ const WatchFolderPage: React.FC = () => {
|
|||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PersonIcon color="primary" />
|
||||
Personal Watch Directory
|
||||
{t('watchFolder.personalWatchDirectory')}
|
||||
{user.role === 'Admin' && (
|
||||
<Chip
|
||||
icon={<AdminIcon />}
|
||||
label="Admin"
|
||||
label={t('watchFolder.admin')}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
|
|
@ -287,7 +289,7 @@ const WatchFolderPage: React.FC = () => {
|
|||
<Grid item xs={12} md={8}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Your Personal Watch Directory
|
||||
{t('watchFolder.yourPersonalWatchDirectory')}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{
|
||||
fontFamily: 'monospace',
|
||||
|
|
@ -308,11 +310,11 @@ const WatchFolderPage: React.FC = () => {
|
|||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Directory Status
|
||||
{t('watchFolder.directoryStatus')}
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={userWatchInfo.exists ? <CheckCircleIcon /> : <ErrorIcon />}
|
||||
label={userWatchInfo.exists ? 'Directory Exists' : 'Directory Missing'}
|
||||
label={userWatchInfo.exists ? t('watchFolder.directoryExists') : t('watchFolder.directoryMissing')}
|
||||
color={userWatchInfo.exists ? 'success' : 'error'}
|
||||
variant="filled"
|
||||
size="small"
|
||||
|
|
@ -320,11 +322,11 @@ const WatchFolderPage: React.FC = () => {
|
|||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Watch Status
|
||||
{t('watchFolder.watchStatus')}
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={userWatchInfo.enabled ? <CheckCircleIcon /> : <ScheduleIcon />}
|
||||
label={userWatchInfo.enabled ? 'Enabled' : 'Disabled'}
|
||||
label={userWatchInfo.enabled ? t('watchFolder.enabled') : t('watchFolder.disabled')}
|
||||
color={userWatchInfo.enabled ? 'success' : 'warning'}
|
||||
variant="filled"
|
||||
size="small"
|
||||
|
|
@ -343,7 +345,7 @@ const WatchFolderPage: React.FC = () => {
|
|||
border: `1px solid ${theme.palette.info.main}`,
|
||||
}}>
|
||||
<Typography variant="body2" sx={{ mb: 2, color: 'info.contrastText' }}>
|
||||
Your personal watch directory doesn't exist yet. Create it to start uploading files to your own dedicated folder.
|
||||
{t('watchFolder.directoryNotExist')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
|
|
@ -353,7 +355,7 @@ const WatchFolderPage: React.FC = () => {
|
|||
disabled={creatingDirectory}
|
||||
sx={{ color: 'primary.contrastText' }}
|
||||
>
|
||||
{creatingDirectory ? 'Creating Directory...' : 'Create Personal Directory'}
|
||||
{creatingDirectory ? t('watchFolder.creatingDirectory') : t('watchFolder.createPersonalDirectory')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
|
@ -361,7 +363,7 @@ const WatchFolderPage: React.FC = () => {
|
|||
</Grid>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
Unable to load personal watch directory information. Please try refreshing the page.
|
||||
{t('watchFolder.unableToLoad')}
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -373,7 +375,7 @@ const WatchFolderPage: React.FC = () => {
|
|||
<Box sx={{ my: 4, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Divider sx={{ flex: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ px: 2 }}>
|
||||
System Configuration
|
||||
{t('watchFolder.systemConfiguration')}
|
||||
</Typography>
|
||||
<Divider sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
|
|
@ -384,10 +386,10 @@ const WatchFolderPage: React.FC = () => {
|
|||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FolderIcon color="primary" />
|
||||
Global Watch Folder Configuration
|
||||
{t('watchFolder.globalWatchFolderConfiguration')}
|
||||
{user?.role === 'Admin' && (
|
||||
<Chip
|
||||
label="Admin Only"
|
||||
label={t('watchFolder.adminOnly')}
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
|
|
@ -397,19 +399,19 @@ const WatchFolderPage: React.FC = () => {
|
|||
</Typography>
|
||||
{user?.role !== 'Admin' && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
This is the system-wide watch folder configuration. All users can view this information.
|
||||
{t('watchFolder.systemWideInfo')}
|
||||
</Alert>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Watched Directory
|
||||
{t('watchFolder.watchedDirectory')}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{
|
||||
fontFamily: 'monospace',
|
||||
bgcolor: theme.palette.mode === 'light' ? 'grey.100' : 'grey.800',
|
||||
p: 1,
|
||||
<Typography variant="body1" sx={{
|
||||
fontFamily: 'monospace',
|
||||
bgcolor: theme.palette.mode === 'light' ? 'grey.100' : 'grey.800',
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
color: 'text.primary',
|
||||
}}>
|
||||
|
|
@ -420,11 +422,11 @@ const WatchFolderPage: React.FC = () => {
|
|||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Status
|
||||
{t('watchFolder.status')}
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={getStatusIcon(watchConfig.isActive ? 'active' : 'error')}
|
||||
label={watchConfig.isActive ? 'Active' : 'Inactive'}
|
||||
label={watchConfig.isActive ? t('watchFolder.active') : t('watchFolder.inactive')}
|
||||
color={getStatusColor(watchConfig.isActive ? 'active' : 'error')}
|
||||
variant="filled"
|
||||
/>
|
||||
|
|
@ -433,7 +435,7 @@ const WatchFolderPage: React.FC = () => {
|
|||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Watch Strategy
|
||||
{t('watchFolder.watchStrategy')}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ textTransform: 'capitalize' }}>
|
||||
{watchConfig.strategy}
|
||||
|
|
@ -443,27 +445,27 @@ const WatchFolderPage: React.FC = () => {
|
|||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Scan Interval
|
||||
{t('watchFolder.scanInterval')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{watchConfig.watchInterval} seconds
|
||||
{t('watchFolder.seconds', { count: watchConfig.watchInterval })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Max File Age
|
||||
{t('watchFolder.maxFileAge')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{watchConfig.maxFileAge} hours
|
||||
{t('watchFolder.hours', { count: watchConfig.maxFileAge })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Supported File Types
|
||||
{t('watchFolder.supportedFileTypes')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{watchConfig.allowedTypes.map((type) => (
|
||||
|
|
@ -488,7 +490,7 @@ const WatchFolderPage: React.FC = () => {
|
|||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CloudUploadIcon color="primary" />
|
||||
Processing Queue
|
||||
{t('watchFolder.processingQueue')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
|
|
@ -501,14 +503,14 @@ const WatchFolderPage: React.FC = () => {
|
|||
borderRadius: 2,
|
||||
border: theme.palette.mode === 'dark' ? '1px solid rgba(2, 136, 209, 0.3)' : 'none'
|
||||
}}>
|
||||
<Typography variant="h4" sx={{
|
||||
fontWeight: 600,
|
||||
color: theme.palette.mode === 'dark' ? '#29b6f6' : 'info.dark'
|
||||
<Typography variant="h4" sx={{
|
||||
fontWeight: 600,
|
||||
color: theme.palette.mode === 'dark' ? '#29b6f6' : 'info.dark'
|
||||
}}>
|
||||
{queueStats.pending_count}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Pending
|
||||
{t('watchFolder.pending')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
|
@ -522,14 +524,14 @@ const WatchFolderPage: React.FC = () => {
|
|||
borderRadius: 2,
|
||||
border: theme.palette.mode === 'dark' ? '1px solid rgba(237, 108, 2, 0.3)' : 'none'
|
||||
}}>
|
||||
<Typography variant="h4" sx={{
|
||||
fontWeight: 600,
|
||||
color: theme.palette.mode === 'dark' ? '#ff9800' : 'warning.dark'
|
||||
<Typography variant="h4" sx={{
|
||||
fontWeight: 600,
|
||||
color: theme.palette.mode === 'dark' ? '#ff9800' : 'warning.dark'
|
||||
}}>
|
||||
{queueStats.processing_count}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Processing
|
||||
{t('watchFolder.processing')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
|
@ -543,35 +545,35 @@ const WatchFolderPage: React.FC = () => {
|
|||
borderRadius: 2,
|
||||
border: theme.palette.mode === 'dark' ? '1px solid rgba(211, 47, 47, 0.3)' : 'none'
|
||||
}}>
|
||||
<Typography variant="h4" sx={{
|
||||
fontWeight: 600,
|
||||
color: theme.palette.mode === 'dark' ? '#ef5350' : 'error.dark'
|
||||
<Typography variant="h4" sx={{
|
||||
fontWeight: 600,
|
||||
color: theme.palette.mode === 'dark' ? '#ef5350' : 'error.dark'
|
||||
}}>
|
||||
{queueStats.failed_count}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Failed
|
||||
{t('watchFolder.failed')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2,
|
||||
bgcolor: theme.palette.mode === 'dark'
|
||||
? 'rgba(46, 125, 50, 0.15)'
|
||||
: 'success.light',
|
||||
<Box sx={{
|
||||
textAlign: 'center',
|
||||
p: 2,
|
||||
bgcolor: theme.palette.mode === 'dark'
|
||||
? 'rgba(46, 125, 50, 0.15)'
|
||||
: 'success.light',
|
||||
borderRadius: 2,
|
||||
border: theme.palette.mode === 'dark' ? '1px solid rgba(46, 125, 50, 0.3)' : 'none'
|
||||
}}>
|
||||
<Typography variant="h4" sx={{
|
||||
fontWeight: 600,
|
||||
color: theme.palette.mode === 'dark' ? '#81c784' : 'success.dark'
|
||||
<Typography variant="h4" sx={{
|
||||
fontWeight: 600,
|
||||
color: theme.palette.mode === 'dark' ? '#81c784' : 'success.dark'
|
||||
}}>
|
||||
{queueStats.completed_today}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Completed Today
|
||||
{t('watchFolder.completedToday')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
|
@ -579,14 +581,14 @@ const WatchFolderPage: React.FC = () => {
|
|||
|
||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
bgcolor: theme.palette.mode === 'light' ? 'grey.50' : 'grey.800',
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
bgcolor: theme.palette.mode === 'light' ? 'grey.50' : 'grey.800',
|
||||
borderRadius: 2,
|
||||
border: theme.palette.mode === 'dark' ? '1px solid rgba(255,255,255,0.1)' : 'none',
|
||||
}}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Average Wait Time
|
||||
{t('watchFolder.averageWaitTime')}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{formatDuration(queueStats.avg_wait_time_minutes)}
|
||||
|
|
@ -594,14 +596,14 @@ const WatchFolderPage: React.FC = () => {
|
|||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
bgcolor: theme.palette.mode === 'light' ? 'grey.50' : 'grey.800',
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
bgcolor: theme.palette.mode === 'light' ? 'grey.50' : 'grey.800',
|
||||
borderRadius: 2,
|
||||
border: theme.palette.mode === 'dark' ? '1px solid rgba(255,255,255,0.1)' : 'none',
|
||||
}}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Oldest Pending Item
|
||||
{t('watchFolder.oldestPendingItem')}
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{formatDuration(queueStats.oldest_pending_minutes)}
|
||||
|
|
@ -612,7 +614,7 @@ const WatchFolderPage: React.FC = () => {
|
|||
|
||||
{lastRefresh && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
|
||||
Last updated: {lastRefresh.toLocaleTimeString()}
|
||||
{t('watchFolder.lastUpdated', { time: lastRefresh.toLocaleTimeString() })}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -624,39 +626,38 @@ const WatchFolderPage: React.FC = () => {
|
|||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DescriptionIcon color="primary" />
|
||||
How Watch Folder Works
|
||||
{t('watchFolder.howWatchFolderWorks')}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
The watch folder system automatically monitors the configured directory for new files and processes them for OCR.
|
||||
{t('watchFolder.watchFolderDescription')}
|
||||
</Typography>
|
||||
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: 'primary.main' }}>
|
||||
Processing Pipeline:
|
||||
{t('watchFolder.processingPipeline')}
|
||||
</Typography>
|
||||
<Box sx={{ pl: 2 }}>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
1. <strong>File Detection:</strong> New files are detected using hybrid watching (inotify + polling)
|
||||
{t('watchFolder.pipelineSteps.fileDetection')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
2. <strong>Validation:</strong> Files are checked for supported format and size limits
|
||||
{t('watchFolder.pipelineSteps.validation')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
3. <strong>Deduplication:</strong> System prevents processing of duplicate files
|
||||
{t('watchFolder.pipelineSteps.deduplication')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
4. <strong>Storage:</strong> Files are moved to the document storage system
|
||||
{t('watchFolder.pipelineSteps.storage')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
5. <strong>OCR Queue:</strong> Documents are queued for OCR processing with priority
|
||||
{t('watchFolder.pipelineSteps.ocrQueue')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
The system uses a hybrid watching strategy that automatically detects filesystem type and chooses
|
||||
the optimal monitoring approach (inotify for local filesystems, polling for network mounts).
|
||||
{t('watchFolder.hybridStrategyInfo')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
|
|
|
|||
Loading…
Reference in New Issue