feat(ui): migrate hardcoded strings to i18n for translations

This commit is contained in:
perf3ct 2025-10-04 14:00:17 -07:00
parent c97e54d664
commit 1cc4c5c813
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
30 changed files with 4587 additions and 1144 deletions

View File

@ -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",

View File

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

View File

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

View File

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

View File

@ -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) => (

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1 @@
export { default } from './LanguageSwitcher';

View File

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

View File

@ -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 }}>

View File

@ -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>
)}

View File

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

View File

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

View File

@ -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;

View File

@ -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';

View File

@ -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>,
)

View File

@ -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) => (

View File

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

View File

@ -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')}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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