Compare commits
1 Commits
main
...
renovate/q
| Author | SHA1 | Date |
|---|---|---|
|
|
1aa4a3287d |
|
|
@ -3527,9 +3527,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
|||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
version = "0.38.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ thiserror = "2.0"
|
|||
sysinfo = "0.37"
|
||||
raw-cpuid = { version = "11", optional = true }
|
||||
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||
quick-xml = { version = "0.38", features = ["serialize"] }
|
||||
urlencoding = "2.1"
|
||||
oauth2 = "4.4"
|
||||
url = "2.4"
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -42,27 +42,11 @@ docker compose up --build -d
|
|||
open http://localhost:8000
|
||||
```
|
||||
|
||||
**Admin credentials:**
|
||||
**Default login credentials:**
|
||||
- Username: `admin`
|
||||
- Password: Auto-generated on first run (check container logs)
|
||||
- Password: `readur2024`
|
||||
|
||||
On first startup, Readur generates a secure admin password and displays it in the logs:
|
||||
```
|
||||
==============================================
|
||||
READUR ADMIN USER CREATED
|
||||
==============================================
|
||||
|
||||
Username: admin
|
||||
Password: [your-generated-password]
|
||||
|
||||
⚠️ SAVE THESE CREDENTIALS IMMEDIATELY!
|
||||
⚠️ This password will not be shown again.
|
||||
==============================================
|
||||
```
|
||||
|
||||
View the logs with: `docker compose logs readur`
|
||||
|
||||
To reset the admin password later, run: `readur reset-admin-password`
|
||||
> ⚠️ **Important**: Change the default admin password immediately after first login!
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
|
|
|
|||
|
|
@ -225,20 +225,17 @@ docker logs -f readur
|
|||
http://localhost:8000
|
||||
```
|
||||
|
||||
2. **Login with Admin Credentials**
|
||||
2. **Login with Default Credentials**
|
||||
- Username: `admin`
|
||||
- Password: Check the container logs for your auto-generated password
|
||||
- Password: `readur2024`
|
||||
|
||||
⚠️ **Security**: Change the admin password immediately after first login
|
||||
|
||||
On first startup, Readur generates a secure admin password and displays it in the logs.
|
||||
View the logs with `docker compose logs readur` and look for "READUR ADMIN USER CREATED".
|
||||
|
||||
**Save this password immediately - it won't be shown again.**
|
||||
|
||||
3. **Resetting Admin Password**
|
||||
If you lose your password, reset it with:
|
||||
```bash
|
||||
docker exec readur readur reset-admin-password
|
||||
```
|
||||
3. **Change Admin Password**
|
||||
- Navigate to Settings → User Management
|
||||
- Click on admin user
|
||||
- Set a strong password
|
||||
- Save changes
|
||||
|
||||
### Essential Configuration
|
||||
|
||||
|
|
@ -487,10 +484,10 @@ deploy:
|
|||
Upload your first document:
|
||||
|
||||
```bash
|
||||
# 1. Login to get token (use your generated password from the logs)
|
||||
# 1. Login to get token
|
||||
TOKEN=$(curl -s -X POST http://localhost:8000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"YOUR_GENERATED_PASSWORD"}' | jq -r .token)
|
||||
-d '{"username":"admin","password":"readur2024"}' | jq -r .token)
|
||||
|
||||
# 2. Upload a PDF
|
||||
curl -X POST http://localhost:8000/api/documents/upload \
|
||||
|
|
|
|||
|
|
@ -39,11 +39,9 @@ Open your browser and navigate to:
|
|||
http://localhost:8000
|
||||
```
|
||||
|
||||
Login with your admin credentials:
|
||||
Login with default credentials:
|
||||
- **Username**: `admin`
|
||||
- **Password**: Check the container logs for the auto-generated password
|
||||
|
||||
On first startup, Readur generates a secure admin password and displays it in the logs. View the logs with `docker-compose logs` and look for the "READUR ADMIN USER CREATED" section. Save this password immediately - it won't be shown again.
|
||||
- **Password**: `readur2024`
|
||||
|
||||
### Step 4: Upload Your First Document
|
||||
|
||||
|
|
@ -56,10 +54,10 @@ Now you can test Readur's core functionality by uploading a document. Click the
|
|||
If you prefer working with APIs or want to automate document uploads, you can use Readur's REST API. First, authenticate to get an access token:
|
||||
|
||||
```bash
|
||||
# Authenticate and get a session token (use your generated password from the logs)
|
||||
# Authenticate and get a session token
|
||||
TOKEN=$(curl -s -X POST http://localhost:8000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"YOUR_GENERATED_PASSWORD"}' | jq -r .token)
|
||||
-d '{"username":"admin","password":"readur2024"}' | jq -r .token)
|
||||
|
||||
# Upload a document using the API
|
||||
curl -X POST http://localhost:8000/api/documents/upload \
|
||||
|
|
@ -75,15 +73,9 @@ Once the OCR indicator shows green (processing complete), you can test Readur's
|
|||
|
||||
## Common First Tasks
|
||||
|
||||
### Resetting Admin Password
|
||||
### Change Admin Password
|
||||
|
||||
If you lose your admin password or need to reset it, you can use the built-in CLI command:
|
||||
|
||||
```bash
|
||||
docker exec readur readur reset-admin-password
|
||||
```
|
||||
|
||||
This generates a new secure password and displays it. You can also set a specific password using the `ADMIN_PASSWORD` environment variable.
|
||||
Security should be your first priority after getting Readur running. The default admin password is publicly documented, so change it immediately to protect your installation. Navigate to **Settings** → **User Management**, click on the admin user entry, enter a strong new password, and save your changes. This single step prevents unauthorized access to your document collection.
|
||||
|
||||
### Add Your First Source
|
||||
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ docker-compose up -d
|
|||
open http://localhost:8000
|
||||
```
|
||||
|
||||
Check the container logs (`docker compose logs readur`) for your auto-generated admin password - look for "READUR ADMIN USER CREATED" and save the password immediately. Log in with username `admin` and your generated password, then upload a document and watch Readur extract the text automatically.
|
||||
Log in with the default credentials (admin / readur2024) and change the password immediately. Then upload a document and watch Readur extract the text automatically.
|
||||
|
||||
## How People Use Readur
|
||||
|
||||
|
|
|
|||
|
|
@ -28,27 +28,11 @@ docker compose up --build -d
|
|||
open http://localhost:8000
|
||||
```
|
||||
|
||||
**Admin credentials:**
|
||||
**Default login credentials:**
|
||||
- Username: `admin`
|
||||
- Password: Auto-generated on first run (check container logs)
|
||||
- Password: `readur2024`
|
||||
|
||||
On first startup, Readur generates a secure admin password and displays it in the logs:
|
||||
```
|
||||
==============================================
|
||||
READUR ADMIN USER CREATED
|
||||
==============================================
|
||||
|
||||
Username: admin
|
||||
Password: [your-generated-password]
|
||||
|
||||
⚠️ SAVE THESE CREDENTIALS IMMEDIATELY!
|
||||
⚠️ This password will not be shown again.
|
||||
==============================================
|
||||
```
|
||||
|
||||
View the logs with: `docker compose logs readur`
|
||||
|
||||
To reset the admin password later, run: `readur reset-admin-password`
|
||||
> ⚠️ **Important**: Change the default admin password immediately after first login!
|
||||
|
||||
### What You Get
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ This guide walks you through everything you need to know to effectively use Read
|
|||
|
||||
## Getting Started
|
||||
|
||||
When you first access Readur, navigate to your installation URL (typically `http://localhost:8000` for local installations) and log in with the admin credentials. The username is `admin` and the password is auto-generated on first startup - check your container logs (`docker compose logs readur`) and look for "READUR ADMIN USER CREATED" to find your password. Save this password immediately as it won't be shown again.
|
||||
When you first access Readur, navigate to your installation URL (typically `http://localhost:8000` for local installations) and log in with the default admin credentials: username `admin` and password `readur2024`. The very first thing you should do is change this default password to something secure.
|
||||
|
||||
Take a moment to configure your user preferences through the settings menu. If you work with documents in languages other than English, set your preferred OCR language now - this will improve text extraction accuracy for your documents. You can also adjust search settings and display preferences to match how you like to work.
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ See the [OIDC Setup Guide](oidc-setup.md) for detailed configuration instruction
|
|||
|
||||
**Default Admin Account:**
|
||||
- Username: `admin`
|
||||
- Password: Auto-generated on first startup (check container logs for "READUR ADMIN USER CREATED")
|
||||
- Default Password: `readur2024` ⚠️ **Change immediately in production!**
|
||||
|
||||
## Admin User Management
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@
|
|||
"i18next": "^25.5.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"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",
|
||||
|
|
@ -35,8 +35,8 @@
|
|||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.1",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"jsdom": "^26.1.0",
|
||||
|
|
@ -105,6 +105,7 @@
|
|||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
|
|
@ -451,6 +452,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -474,6 +476,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -536,6 +539,7 @@
|
|||
"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,6 +583,7 @@
|
|||
"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",
|
||||
|
|
@ -1189,6 +1194,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.6.tgz",
|
||||
"integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/core-downloads-tracker": "^7.3.6",
|
||||
|
|
@ -1299,6 +1305,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.6.tgz",
|
||||
"integrity": "sha512-8fehAazkHNP1imMrdD2m2hbA9sl7Ur6jfuNweh5o4l9YPty4iaZzRXqYvBCWQNwFaSHmMEj2KPbyXGp7Bt73Rg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/private-theming": "^7.3.6",
|
||||
|
|
@ -1496,9 +1503,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.53",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
||||
"integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==",
|
||||
"version": "1.0.0-beta.47",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
|
||||
"integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
@ -1877,8 +1884,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
|
|
@ -1930,7 +1936,8 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
|
||||
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/chai-subset": {
|
||||
"version": "1.3.6",
|
||||
|
|
@ -1955,6 +1962,7 @@
|
|||
"integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.8.0"
|
||||
}
|
||||
|
|
@ -1976,6 +1984,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -1986,6 +1995,7 @@
|
|||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -2010,16 +2020,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
|
||||
"integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==",
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz",
|
||||
"integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.53",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.47",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.18.0"
|
||||
},
|
||||
|
|
@ -2130,7 +2140,6 @@
|
|||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
|
|
@ -2270,6 +2279,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
|
|
@ -2369,7 +2379,6 @@
|
|||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
|
|
@ -2426,7 +2435,6 @@
|
|||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
|
|
@ -2439,8 +2447,7 @@
|
|||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
|
|
@ -2556,6 +2563,7 @@
|
|||
"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"
|
||||
|
|
@ -2632,8 +2640,7 @@
|
|||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
|
|
@ -3001,7 +3008,6 @@
|
|||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
|
@ -3111,9 +3117,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.7.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.2.tgz",
|
||||
"integrity": "sha512-58b4kmLpLv1buWUEwegMDUqZVR5J+rT+WTRFaBGL7lxDuJQQ0NrJFrq+eT2N94aYVR1k1Sr13QITNOL88tZCuw==",
|
||||
"version": "25.7.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.1.tgz",
|
||||
"integrity": "sha512-XbTnkh1yCZWSAZGnA9xcQfHcYNgZs2cNxm+c6v1Ma9UAUGCeJPplRe1ILia6xnDvXBjk0uXU+Z8FYWhA19SKFw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
|
@ -3129,6 +3135,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
|
|
@ -3238,6 +3245,7 @@
|
|||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
|
|
@ -3360,7 +3368,6 @@
|
|||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
|
|
@ -3628,6 +3635,7 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -3706,6 +3714,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
|
|
@ -3795,6 +3804,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -3804,6 +3814,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
||||
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
|
|
@ -4268,7 +4279,6 @@
|
|||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
|
|
@ -4414,6 +4424,7 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -4490,11 +4501,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
|
||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||
"version": "7.2.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz",
|
||||
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@
|
|||
"i18next": "^25.5.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"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",
|
||||
|
|
@ -46,8 +46,8 @@
|
|||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.1",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"jsdom": "^26.1.0",
|
||||
|
|
|
|||
|
|
@ -1489,16 +1489,6 @@
|
|||
"labels": {
|
||||
"recommended": "Empfohlen",
|
||||
"notAvailable": "Nicht verfügbar"
|
||||
},
|
||||
"urlPreview": {
|
||||
"title": "Beispiel-Sync-URL:",
|
||||
"legend": {
|
||||
"serverUrl": "Server-URL",
|
||||
"webdavPath": "WebDAV-Pfad",
|
||||
"bucketPrefix": "Bucket/Präfix",
|
||||
"watchDirectory": "Überwachungsverzeichnis",
|
||||
"exampleFile": "Beispieldatei"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -545,13 +545,13 @@
|
|||
"retrying": "Retrying All...",
|
||||
"retryFailedOnly": "Retry Failed Only",
|
||||
"tabs": {
|
||||
"failedDocuments": "Failed Documents{{showCount, select, true { ({{count}}) } other {}}}",
|
||||
"failedDocuments": "Failed Documents{{showCount, select, true { ({{count}}) } other {}}",
|
||||
"failedDocumentsTooltip": "View and manage documents that failed during processing (OCR, ingestion, validation, etc.)",
|
||||
"cleanup": "Document Cleanup{{showCount, select, true { ({{count}}) } other {}}}",
|
||||
"cleanup": "Document Cleanup{{showCount, select, true { ({{count}}) } other {}}",
|
||||
"cleanupTooltip": "Manage and clean up documents with quality issues - low OCR confidence or failed processing",
|
||||
"duplicates": "Duplicate Files{{showCount, select, true { ({{count}}) } other {}}}",
|
||||
"duplicates": "Duplicate Files{{showCount, select, true { ({{count}}) } other {}}",
|
||||
"duplicatesTooltip": "View and manage duplicate document groups - documents with identical content",
|
||||
"ignoredFiles": "Ignored Files{{showCount, select, true { ({{count}}) } other {}}}",
|
||||
"ignoredFiles": "Ignored Files{{showCount, select, true { ({{count}}) } other {}}",
|
||||
"ignoredFilesTooltip": "Manage files that have been ignored during sync operations"
|
||||
},
|
||||
"stats": {
|
||||
|
|
@ -1489,16 +1489,6 @@
|
|||
"labels": {
|
||||
"recommended": "Recommended",
|
||||
"notAvailable": "Not Available"
|
||||
},
|
||||
"urlPreview": {
|
||||
"title": "Example sync URL:",
|
||||
"legend": {
|
||||
"serverUrl": "Server URL",
|
||||
"webdavPath": "WebDAV Path",
|
||||
"bucketPrefix": "Bucket/Prefix",
|
||||
"watchDirectory": "Watch Directory",
|
||||
"exampleFile": "Example File"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1489,16 +1489,6 @@
|
|||
"labels": {
|
||||
"recommended": "Recomendado",
|
||||
"notAvailable": "No Disponible"
|
||||
},
|
||||
"urlPreview": {
|
||||
"title": "URL de sincronización de ejemplo:",
|
||||
"legend": {
|
||||
"serverUrl": "URL del servidor",
|
||||
"webdavPath": "Ruta WebDAV",
|
||||
"bucketPrefix": "Bucket/Prefijo",
|
||||
"watchDirectory": "Directorio vigilado",
|
||||
"exampleFile": "Archivo de ejemplo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1489,16 +1489,6 @@
|
|||
"labels": {
|
||||
"recommended": "Recommandé",
|
||||
"notAvailable": "Non disponible"
|
||||
},
|
||||
"urlPreview": {
|
||||
"title": "Exemple d'URL de synchronisation :",
|
||||
"legend": {
|
||||
"serverUrl": "URL du serveur",
|
||||
"webdavPath": "Chemin WebDAV",
|
||||
"bucketPrefix": "Bucket/Préfixe",
|
||||
"watchDirectory": "Répertoire surveillé",
|
||||
"exampleFile": "Fichier exemple"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -367,11 +367,7 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
|||
}}
|
||||
sx={{
|
||||
width: '100%',
|
||||
minWidth: {
|
||||
xs: '200px', // Mobile: minimum viable width
|
||||
sm: '400px', // Small tablets
|
||||
md: 600, // Desktop: original size
|
||||
},
|
||||
minWidth: 600,
|
||||
maxWidth: 1200,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
background: theme.palette.mode === 'light'
|
||||
|
|
|
|||
|
|
@ -130,9 +130,7 @@ const LabelCreateDialog: React.FC<LabelCreateDialogProps> = ({
|
|||
background_color: formData.background_color || undefined,
|
||||
icon: formData.icon || undefined,
|
||||
});
|
||||
// Call onClose directly after successful submission
|
||||
// Don't use handleClose() here to avoid race conditions with loading state
|
||||
onClose();
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to save label:', error);
|
||||
// Could add error handling UI here
|
||||
|
|
|
|||
|
|
@ -46,8 +46,6 @@ import GlobalSearchBar from '../GlobalSearchBar';
|
|||
import ThemeToggle from '../ThemeToggle/ThemeToggle';
|
||||
import NotificationPanel from '../Notifications/NotificationPanel';
|
||||
import LanguageSwitcher from '../LanguageSwitcher';
|
||||
import BottomNavigation from './BottomNavigation';
|
||||
import { usePWA } from '../../hooks/usePWA';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const drawerWidth = 280;
|
||||
|
|
@ -82,7 +80,6 @@ const getNavigationItems = (t: (key: string) => string): NavigationItem[] => [
|
|||
const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
||||
const theme = useMuiTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const isPWA = usePWA();
|
||||
const [mobileOpen, setMobileOpen] = useState<boolean>(false);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [notificationAnchorEl, setNotificationAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
|
@ -441,7 +438,6 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
fontWeight: 700,
|
||||
mr: 1,
|
||||
fontSize: '1.1rem',
|
||||
display: isPWA ? 'none' : 'block',
|
||||
background: theme.palette.mode === 'light'
|
||||
? 'linear-gradient(135deg, #1e293b 0%, #6366f1 100%)'
|
||||
: 'linear-gradient(135deg, #f8fafc 0%, #a855f7 100%)',
|
||||
|
|
@ -456,24 +452,15 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
</Typography>
|
||||
|
||||
{/* Global Search Bar */}
|
||||
<Box sx={{
|
||||
flexGrow: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mx: { xs: 0.5, md: 1 },
|
||||
flex: '1 1 auto',
|
||||
minWidth: { xs: 0, md: 'auto' },
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Box sx={{ flexGrow: 2, display: 'flex', justifyContent: 'center', mx: 1, flex: '1 1 auto' }}>
|
||||
<GlobalSearchBar />
|
||||
</Box>
|
||||
|
||||
{/* Notifications */}
|
||||
<IconButton
|
||||
<IconButton
|
||||
onClick={handleNotificationClick}
|
||||
sx={{
|
||||
mr: { xs: 1, md: 2 },
|
||||
display: isPWA ? 'none' : 'flex',
|
||||
sx={{
|
||||
mr: 2,
|
||||
color: 'text.secondary',
|
||||
background: theme.palette.mode === 'light'
|
||||
? 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(248,250,252,0.6) 100%)'
|
||||
|
|
@ -493,8 +480,8 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={unreadCount}
|
||||
<Badge
|
||||
badgeContent={unreadCount}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
background: 'linear-gradient(135deg, #ef4444 0%, #f97316 100%)',
|
||||
|
|
@ -510,8 +497,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
|
||||
{/* Language Switcher */}
|
||||
<Box sx={{
|
||||
mr: { xs: 1, md: 2 },
|
||||
display: isPWA ? 'none' : { xs: 'none', sm: 'block' },
|
||||
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%)',
|
||||
|
|
@ -532,7 +518,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
|
||||
{/* Theme Toggle */}
|
||||
<Box sx={{
|
||||
mr: { xs: 1, md: 2 },
|
||||
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%)',
|
||||
|
|
@ -674,23 +660,16 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
|||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
// Add bottom padding when bottom nav is visible (PWA mode on mobile)
|
||||
pb: isPWA && isMobile ? 'calc(64px + 24px + 8px + env(safe-area-inset-bottom, 0px))' : 3,
|
||||
}}>
|
||||
<Box sx={{ p: 3 }}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Notification Panel */}
|
||||
<NotificationPanel
|
||||
anchorEl={notificationAnchorEl}
|
||||
onClose={handleNotificationClose}
|
||||
<NotificationPanel
|
||||
anchorEl={notificationAnchorEl}
|
||||
onClose={handleNotificationClose}
|
||||
/>
|
||||
|
||||
{/* Bottom Navigation (PWA only) */}
|
||||
<BottomNavigation />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,192 +0,0 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
BottomNavigation as MuiBottomNavigation,
|
||||
BottomNavigationAction,
|
||||
Paper,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Dashboard as DashboardIcon,
|
||||
CloudUpload as UploadIcon,
|
||||
Label as LabelIcon,
|
||||
Settings as SettingsIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePWA } from '../../hooks/usePWA';
|
||||
|
||||
const BottomNavigation: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const isPWA = usePWA();
|
||||
|
||||
// Don't render if not in PWA mode
|
||||
if (!isPWA) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map paths to nav values
|
||||
const getNavValue = (pathname: string): string => {
|
||||
if (pathname === '/dashboard') return 'dashboard';
|
||||
if (pathname === '/upload') return 'upload';
|
||||
if (pathname === '/labels') return 'labels';
|
||||
if (pathname === '/settings' || pathname === '/profile') return 'settings';
|
||||
return 'dashboard';
|
||||
};
|
||||
|
||||
const handleNavigation = (_event: React.SyntheticEvent, newValue: string) => {
|
||||
switch (newValue) {
|
||||
case 'dashboard':
|
||||
navigate('/dashboard');
|
||||
break;
|
||||
case 'upload':
|
||||
navigate('/upload');
|
||||
break;
|
||||
case 'labels':
|
||||
navigate('/labels');
|
||||
break;
|
||||
case 'settings':
|
||||
navigate('/settings');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1100,
|
||||
display: { xs: 'block', md: 'none' },
|
||||
background: theme.palette.mode === 'light'
|
||||
? 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(248,250,252,0.98) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(30,30,30,0.98) 0%, rgba(18,18,18,0.98) 100%)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderTop: theme.palette.mode === 'light'
|
||||
? '1px solid rgba(226,232,240,0.5)'
|
||||
: '1px solid rgba(255,255,255,0.1)',
|
||||
boxShadow: theme.palette.mode === 'light'
|
||||
? '0 -4px 32px rgba(0,0,0,0.08)'
|
||||
: '0 -4px 32px rgba(0,0,0,0.3)',
|
||||
// iOS safe area support - add 8px fixed padding for extra space
|
||||
paddingBottom: 'calc(8px + env(safe-area-inset-bottom, 0px))',
|
||||
}}
|
||||
elevation={0}
|
||||
>
|
||||
<MuiBottomNavigation
|
||||
value={getNavValue(location.pathname)}
|
||||
onChange={handleNavigation}
|
||||
sx={{
|
||||
background: 'transparent',
|
||||
height: '64px',
|
||||
'& .MuiBottomNavigationAction-root': {
|
||||
color: 'text.secondary',
|
||||
minWidth: 'auto',
|
||||
padding: '8px 12px',
|
||||
gap: '4px',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'& .MuiBottomNavigationAction-label': {
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.025em',
|
||||
marginTop: '4px',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&.Mui-selected': {
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.5rem',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
color: '#6366f1',
|
||||
'& .MuiSvgIcon-root': {
|
||||
transform: 'scale(1.1)',
|
||||
filter: 'drop-shadow(0 2px 8px rgba(99,102,241,0.3))',
|
||||
},
|
||||
},
|
||||
// iOS-style touch feedback
|
||||
'@media (pointer: coarse)': {
|
||||
minHeight: '56px',
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<BottomNavigationAction
|
||||
label={t('navigation.dashboard')}
|
||||
value="dashboard"
|
||||
icon={<DashboardIcon />}
|
||||
sx={{
|
||||
'&.Mui-selected': {
|
||||
'& .MuiBottomNavigationAction-label': {
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label={t('navigation.upload')}
|
||||
value="upload"
|
||||
icon={<UploadIcon />}
|
||||
sx={{
|
||||
'&.Mui-selected': {
|
||||
'& .MuiBottomNavigationAction-label': {
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label={t('navigation.labels')}
|
||||
value="labels"
|
||||
icon={<LabelIcon />}
|
||||
sx={{
|
||||
'&.Mui-selected': {
|
||||
'& .MuiBottomNavigationAction-label': {
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label={t('settings.title')}
|
||||
value="settings"
|
||||
icon={<SettingsIcon />}
|
||||
sx={{
|
||||
'&.Mui-selected': {
|
||||
'& .MuiBottomNavigationAction-label': {
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</MuiBottomNavigation>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomNavigation;
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, fireEvent, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import BottomNavigation from '../BottomNavigation';
|
||||
import { renderWithPWA, renderWithProviders } from '../../../test/test-utils';
|
||||
import { setupPWAMode, resetPWAMocks } from '../../../test/pwa-test-utils';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
// Mock the usePWA hook
|
||||
vi.mock('../../../hooks/usePWA');
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
BrowserRouter: ({ children, ...props }: { children: React.ReactNode; [key: string]: any }) => (
|
||||
<actual.MemoryRouter initialEntries={props.initialEntries || ['/dashboard']} {...props}>
|
||||
{children}
|
||||
</actual.MemoryRouter>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
describe('BottomNavigation', () => {
|
||||
beforeEach(() => {
|
||||
mockNavigate.mockClear();
|
||||
resetPWAMocks();
|
||||
});
|
||||
|
||||
describe('PWA Detection', () => {
|
||||
it('returns null when not in PWA mode', () => {
|
||||
setupPWAMode(false);
|
||||
|
||||
const { container } = renderWithProviders(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders when in PWA mode', () => {
|
||||
setupPWAMode(true);
|
||||
|
||||
renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
// Check that the navigation is rendered by looking for nav items text
|
||||
expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation Items', () => {
|
||||
beforeEach(() => {
|
||||
setupPWAMode(true);
|
||||
});
|
||||
|
||||
it('renders all 4 navigation items', () => {
|
||||
renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/upload/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/labels/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/settings/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders clickable Dashboard nav button', () => {
|
||||
renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/upload'] },
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const dashboardButton = buttons.find(btn => btn.textContent?.includes('Dashboard'))!;
|
||||
|
||||
expect(dashboardButton).toBeInTheDocument();
|
||||
expect(dashboardButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders clickable Upload nav button', () => {
|
||||
renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const uploadButton = buttons.find(btn => btn.textContent?.includes('Upload'))!;
|
||||
|
||||
expect(uploadButton).toBeInTheDocument();
|
||||
expect(uploadButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders clickable Labels nav button', () => {
|
||||
renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const labelsButton = buttons.find(btn => btn.textContent?.includes('Labels'))!;
|
||||
|
||||
expect(labelsButton).toBeInTheDocument();
|
||||
expect(labelsButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders clickable Settings nav button', () => {
|
||||
renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const settingsButton = buttons.find(btn => btn.textContent?.includes('Settings'))!;
|
||||
|
||||
expect(settingsButton).toBeInTheDocument();
|
||||
expect(settingsButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Routing Integration', () => {
|
||||
beforeEach(() => {
|
||||
setupPWAMode(true);
|
||||
});
|
||||
|
||||
it('uses location pathname to determine active navigation item', () => {
|
||||
renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
// Verify all navigation buttons are present
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(4);
|
||||
|
||||
// Verify buttons have the expected text content
|
||||
expect(buttons.some(btn => btn.textContent?.includes('Dashboard'))).toBe(true);
|
||||
expect(buttons.some(btn => btn.textContent?.includes('Upload'))).toBe(true);
|
||||
expect(buttons.some(btn => btn.textContent?.includes('Labels'))).toBe(true);
|
||||
expect(buttons.some(btn => btn.textContent?.includes('Settings'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
beforeEach(() => {
|
||||
setupPWAMode(true);
|
||||
});
|
||||
|
||||
it('has safe-area-inset padding', () => {
|
||||
const { container } = renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
const paper = container.querySelector('[class*="MuiPaper-root"]');
|
||||
expect(paper).toBeInTheDocument();
|
||||
|
||||
// Check for safe-area padding in style (MUI applies this via sx prop)
|
||||
const computedStyle = window.getComputedStyle(paper!);
|
||||
// Note: We can't directly test the calc() value in JSDOM,
|
||||
// but we verify the component renders without error
|
||||
expect(paper).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has correct z-index for overlay', () => {
|
||||
const { container } = renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
const paper = container.querySelector('[class*="MuiPaper-root"]');
|
||||
expect(paper).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has fixed position at bottom', () => {
|
||||
const { container } = renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
const paper = container.querySelector('[class*="MuiPaper-root"]');
|
||||
expect(paper).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
beforeEach(() => {
|
||||
setupPWAMode(true);
|
||||
});
|
||||
|
||||
it('has visible text labels for all nav items', () => {
|
||||
renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
// All buttons should have visible text
|
||||
expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/upload/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/labels/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/settings/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('all nav items are keyboard accessible', () => {
|
||||
renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const dashboardButton = buttons.find(btn => btn.textContent?.includes('Dashboard'))!;
|
||||
const uploadButton = buttons.find(btn => btn.textContent?.includes('Upload'))!;
|
||||
const labelsButton = buttons.find(btn => btn.textContent?.includes('Labels'))!;
|
||||
const settingsButton = buttons.find(btn => btn.textContent?.includes('Settings'))!;
|
||||
|
||||
// All should be focusable (button elements)
|
||||
expect(dashboardButton.tagName).toBe('BUTTON');
|
||||
expect(uploadButton.tagName).toBe('BUTTON');
|
||||
expect(labelsButton.tagName).toBe('BUTTON');
|
||||
expect(settingsButton.tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('shows visual labels for screen readers', () => {
|
||||
renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
// Text content should be visible (not just icons)
|
||||
expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/upload/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/labels/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/settings/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Behavior', () => {
|
||||
beforeEach(() => {
|
||||
setupPWAMode(true);
|
||||
});
|
||||
|
||||
it('renders in PWA mode', () => {
|
||||
const { container } = renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
// Should render when in PWA mode
|
||||
expect(container.querySelector('[class*="MuiPaper-root"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Stability', () => {
|
||||
beforeEach(() => {
|
||||
setupPWAMode(true);
|
||||
});
|
||||
|
||||
it('renders consistently across re-renders', () => {
|
||||
const { rerender } = renderWithPWA(<BottomNavigation />, {
|
||||
routerProps: { initialEntries: ['/dashboard'] },
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(4);
|
||||
|
||||
// Re-render should maintain same structure
|
||||
rerender(<BottomNavigation />);
|
||||
|
||||
const buttonsAfterRerender = screen.getAllByRole('button');
|
||||
expect(buttonsAfterRerender).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,250 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { usePWA } from '../usePWA';
|
||||
import { setupPWAMode, setupIOSPWAMode, resetPWAMocks } from '../../test/pwa-test-utils';
|
||||
|
||||
describe('usePWA', () => {
|
||||
// Clean up after each test to prevent pollution
|
||||
afterEach(() => {
|
||||
resetPWAMocks();
|
||||
});
|
||||
|
||||
describe('PWA Detection', () => {
|
||||
it('returns false when not in standalone mode', () => {
|
||||
// Setup: not in PWA mode
|
||||
setupPWAMode(false);
|
||||
|
||||
const { result } = renderHook(() => usePWA());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when display-mode is standalone', () => {
|
||||
// Setup: PWA mode via display-mode
|
||||
setupPWAMode(true);
|
||||
|
||||
const { result } = renderHook(() => usePWA());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when navigator.standalone is true (iOS)', () => {
|
||||
// Setup: iOS PWA mode (not using matchMedia)
|
||||
setupPWAMode(false); // matchMedia returns false
|
||||
setupIOSPWAMode(true); // But iOS standalone is true
|
||||
|
||||
const { result } = renderHook(() => usePWA());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when both display-mode and iOS standalone are true', () => {
|
||||
// Setup: Both detection methods return true
|
||||
setupPWAMode(true);
|
||||
setupIOSPWAMode(true);
|
||||
|
||||
const { result } = renderHook(() => usePWA());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Listener Management', () => {
|
||||
it('registers event listener on mount', () => {
|
||||
const addEventListener = vi.fn();
|
||||
const removeEventListener = vi.fn();
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: '(display-mode: standalone)',
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
renderHook(() => usePWA());
|
||||
|
||||
expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
});
|
||||
|
||||
it('removes event listener on unmount', () => {
|
||||
const addEventListener = vi.fn();
|
||||
const removeEventListener = vi.fn();
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: '(display-mode: standalone)',
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
const { unmount } = renderHook(() => usePWA());
|
||||
|
||||
// Capture the registered handler
|
||||
const registeredHandler = addEventListener.mock.calls[0][1];
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListener).toHaveBeenCalledWith('change', registeredHandler);
|
||||
});
|
||||
|
||||
it('handles multiple mount/unmount cycles correctly', () => {
|
||||
setupPWAMode(false);
|
||||
|
||||
// First mount
|
||||
const { unmount: unmount1 } = renderHook(() => usePWA());
|
||||
unmount1();
|
||||
|
||||
// Second mount (should not cause errors)
|
||||
const { result: result2, unmount: unmount2 } = renderHook(() => usePWA());
|
||||
expect(result2.current).toBe(false);
|
||||
unmount2();
|
||||
|
||||
// Third mount with PWA enabled
|
||||
setupPWAMode(true);
|
||||
const { result: result3 } = renderHook(() => usePWA());
|
||||
expect(result3.current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Display Mode Changes', () => {
|
||||
it('updates state when display-mode changes', () => {
|
||||
let matchesValue = false;
|
||||
const listeners: Array<() => void> = [];
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
get matches() {
|
||||
return matchesValue;
|
||||
},
|
||||
media: '(display-mode: standalone)',
|
||||
addEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
listeners.push(handler);
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => usePWA());
|
||||
|
||||
// Initially not in PWA mode
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Simulate entering PWA mode
|
||||
act(() => {
|
||||
matchesValue = true;
|
||||
// Trigger the change event
|
||||
listeners.forEach(handler => handler());
|
||||
});
|
||||
rerender();
|
||||
|
||||
// Should now detect PWA mode
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('updates state when exiting PWA mode', () => {
|
||||
let matchesValue = true;
|
||||
const listeners: Array<() => void> = [];
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
get matches() {
|
||||
return matchesValue;
|
||||
},
|
||||
media: '(display-mode: standalone)',
|
||||
addEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
listeners.push(handler);
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => usePWA());
|
||||
|
||||
// Initially in PWA mode
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Simulate exiting PWA mode
|
||||
act(() => {
|
||||
matchesValue = false;
|
||||
// Trigger the change event
|
||||
listeners.forEach(handler => handler());
|
||||
});
|
||||
rerender();
|
||||
|
||||
// Should now detect non-PWA mode
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles missing navigator.standalone gracefully', () => {
|
||||
// Setup matchMedia to return false
|
||||
setupPWAMode(false);
|
||||
|
||||
// Ensure navigator.standalone is undefined
|
||||
const originalStandalone = (window.navigator as any).standalone;
|
||||
delete (window.navigator as any).standalone;
|
||||
|
||||
const { result } = renderHook(() => usePWA());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Restore original value if it existed
|
||||
if (originalStandalone !== undefined) {
|
||||
(window.navigator as any).standalone = originalStandalone;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Consistency', () => {
|
||||
it('returns the same value on re-renders if conditions unchanged', () => {
|
||||
setupPWAMode(true);
|
||||
|
||||
const { result, rerender } = renderHook(() => usePWA());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Re-render multiple times
|
||||
rerender();
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
rerender();
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('maintains state across re-renders', () => {
|
||||
setupPWAMode(false);
|
||||
|
||||
const { result, rerender } = renderHook(() => usePWA());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
rerender();
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to detect if the app is running in PWA/standalone mode
|
||||
* @returns boolean indicating if running as installed PWA
|
||||
*/
|
||||
export const usePWA = (): boolean => {
|
||||
const [isPWA, setIsPWA] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkPWAMode = () => {
|
||||
// Check if running in standalone mode (installed PWA)
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
|
||||
// iOS Safari specific check
|
||||
const isIOSStandalone = (window.navigator as any).standalone === true;
|
||||
|
||||
setIsPWA(isStandalone || isIOSStandalone);
|
||||
};
|
||||
|
||||
checkPWAMode();
|
||||
|
||||
// Listen for display mode changes
|
||||
const mediaQuery = window.matchMedia('(display-mode: standalone)');
|
||||
const handleChange = () => checkPWAMode();
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
return isPWA;
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
|
|
@ -108,11 +108,10 @@ const DocumentsPage: React.FC = () => {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState<string>('');
|
||||
const [sortBy, setSortBy] = useState<SortField>('created_at');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
||||
const [ocrFilter, setOcrFilter] = useState<string>('');
|
||||
|
||||
|
||||
// Labels state
|
||||
const [availableLabels, setAvailableLabels] = useState<LabelData[]>([]);
|
||||
const [labelsLoading, setLabelsLoading] = useState<boolean>(false);
|
||||
|
|
@ -141,57 +140,24 @@ const DocumentsPage: React.FC = () => {
|
|||
const [retryHistoryModalOpen, setRetryHistoryModalOpen] = useState<boolean>(false);
|
||||
const [selectedDocumentForHistory, setSelectedDocumentForHistory] = useState<string | null>(null);
|
||||
|
||||
// Debounce search query to avoid making too many requests
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
// Reset to first page when search query changes
|
||||
if (searchQuery !== debouncedSearchQuery) {
|
||||
setPagination(prev => ({ ...prev, offset: 0 }));
|
||||
}
|
||||
}, 300); // 300ms debounce delay
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
fetchLabels();
|
||||
}, [pagination?.limit, pagination?.offset, ocrFilter, debouncedSearchQuery]);
|
||||
}, [pagination?.limit, pagination?.offset, ocrFilter]);
|
||||
|
||||
const fetchDocuments = async (): Promise<void> => {
|
||||
if (!pagination) return;
|
||||
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// If there's a search query, use the search API to search all documents
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
const response = await documentService.enhancedSearch({
|
||||
query: debouncedSearchQuery.trim(),
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
include_snippets: false,
|
||||
});
|
||||
|
||||
setDocuments(response.data.documents || []);
|
||||
setPagination({
|
||||
total: response.data.total || 0,
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
has_more: (pagination.offset + pagination.limit) < (response.data.total || 0)
|
||||
});
|
||||
} else {
|
||||
// Otherwise, use normal pagination to list recent documents
|
||||
const response = await documentService.listWithPagination(
|
||||
pagination.limit,
|
||||
pagination.offset,
|
||||
ocrFilter || undefined
|
||||
);
|
||||
// Backend returns wrapped object with documents and pagination
|
||||
setDocuments(response.data.documents || []);
|
||||
setPagination(response.data.pagination || { total: 0, limit: 20, offset: 0, has_more: false });
|
||||
}
|
||||
const response = await documentService.listWithPagination(
|
||||
pagination.limit,
|
||||
pagination.offset,
|
||||
ocrFilter || undefined
|
||||
);
|
||||
// Backend returns wrapped object with documents and pagination
|
||||
setDocuments(response.data.documents || []);
|
||||
setPagination(response.data.pagination || { total: 0, limit: 20, offset: 0, has_more: false });
|
||||
} catch (err) {
|
||||
setError(t('common.status.error'));
|
||||
console.error(err);
|
||||
|
|
@ -297,9 +263,12 @@ const DocumentsPage: React.FC = () => {
|
|||
});
|
||||
};
|
||||
|
||||
// No need for client-side filtering anymore - search is done on the server
|
||||
// When searchQuery is set, documents are already filtered by the server-side search API
|
||||
const sortedDocuments = [...(documents || [])].sort((a, b) => {
|
||||
const filteredDocuments = (documents || []).filter(doc =>
|
||||
doc.original_filename.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
doc.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
const sortedDocuments = [...filteredDocuments].sort((a, b) => {
|
||||
let aValue: any = a[sortBy];
|
||||
let bValue: any = b[sortBy];
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ import { useAuth } from '../contexts/AuthContext';
|
|||
import api, { queueService, ErrorHelper, ErrorCodes, userWatchService, UserWatchDirectoryResponse } from '../services/api';
|
||||
import OcrLanguageSelector from '../components/OcrLanguageSelector';
|
||||
import LanguageSelector from '../components/LanguageSelector';
|
||||
import { usePWA } from '../hooks/usePWA';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface User {
|
||||
|
|
@ -195,7 +194,6 @@ function useDebounce<T extends (...args: any[]) => any>(func: T, delay: number):
|
|||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user: currentUser } = useAuth();
|
||||
const isPWA = usePWA();
|
||||
const [tabValue, setTabValue] = useState<number>(0);
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
ocrLanguage: 'eng',
|
||||
|
|
@ -839,41 +837,20 @@ const SettingsPage: React.FC = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Container
|
||||
maxWidth="lg"
|
||||
sx={{
|
||||
mt: 4,
|
||||
mb: 4,
|
||||
px: isPWA ? { xs: 1, sm: 2, md: 3 } : undefined,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" sx={{ mb: 4, px: isPWA ? { xs: 1, sm: 0 } : 0 }}>
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" sx={{ mb: 4 }}>
|
||||
{t('settings.title')}
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ width: '100%' }}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={handleTabChange}
|
||||
aria-label="settings tabs"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
sx={{
|
||||
'& .MuiTabs-scrollButtons': {
|
||||
'&.Mui-disabled': {
|
||||
opacity: 0.3,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs value={tabValue} onChange={handleTabChange} aria-label="settings tabs">
|
||||
<Tab label={t('settings.tabs.general')} />
|
||||
<Tab label={t('settings.tabs.ocrSettings')} />
|
||||
<Tab label={t('settings.tabs.userManagement')} />
|
||||
<Tab label={t('settings.tabs.serverConfiguration')} />
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Box sx={{ p: 3 }}>
|
||||
{tabValue === 0 && (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 3 }}>
|
||||
|
|
|
|||
|
|
@ -279,170 +279,6 @@ const SourcesPage: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Helper function to build example sync URL based on source type and configuration
|
||||
const buildExampleSyncUrl = (): { parts: { text: string; type: 'server' | 'path' | 'folder' | 'file' }[] } | null => {
|
||||
const exampleFile = 'document1.pdf';
|
||||
const firstFolder = formData.watch_folders.length > 0 ? formData.watch_folders[0] : '/Documents';
|
||||
|
||||
if (formData.source_type === 'webdav') {
|
||||
if (!formData.server_url) return null;
|
||||
|
||||
let serverUrl = formData.server_url.trim();
|
||||
// Add https:// if no protocol specified
|
||||
if (!serverUrl.startsWith('http://') && !serverUrl.startsWith('https://')) {
|
||||
serverUrl = `https://${serverUrl}`;
|
||||
}
|
||||
serverUrl = serverUrl.replace(/\/+$/, ''); // Remove trailing slashes
|
||||
|
||||
let webdavPath = '';
|
||||
if (formData.server_type === 'nextcloud') {
|
||||
// Nextcloud uses /remote.php/dav/files/{username}
|
||||
if (!serverUrl.includes('/remote.php/dav/files/')) {
|
||||
webdavPath = `/remote.php/dav/files/${formData.username || 'username'}`;
|
||||
}
|
||||
} else if (formData.server_type === 'owncloud') {
|
||||
// ownCloud uses /remote.php/webdav
|
||||
if (!serverUrl.includes('/remote.php/webdav')) {
|
||||
webdavPath = '/remote.php/webdav';
|
||||
}
|
||||
}
|
||||
// For generic, use the URL as-is
|
||||
|
||||
const cleanFolder = firstFolder.replace(/^\/+/, ''); // Remove leading slashes
|
||||
|
||||
return {
|
||||
parts: [
|
||||
{ text: serverUrl, type: 'server' },
|
||||
{ text: webdavPath, type: 'path' },
|
||||
{ text: `/${cleanFolder}`, type: 'folder' },
|
||||
{ text: `/${exampleFile}`, type: 'file' },
|
||||
],
|
||||
};
|
||||
} else if (formData.source_type === 's3') {
|
||||
if (!formData.bucket_name) return null;
|
||||
|
||||
const endpoint = formData.endpoint_url?.trim() || `https://s3.${formData.region || 'us-east-1'}.amazonaws.com`;
|
||||
const cleanEndpoint = endpoint.replace(/\/+$/, '');
|
||||
const prefix = formData.prefix?.trim().replace(/^\/+|\/+$/g, '') || '';
|
||||
const cleanFolder = firstFolder.replace(/^\/+|\/+$/, '');
|
||||
|
||||
const parts: { text: string; type: 'server' | 'path' | 'folder' | 'file' }[] = [
|
||||
{ text: cleanEndpoint, type: 'server' },
|
||||
{ text: `/${formData.bucket_name}`, type: 'path' },
|
||||
{ text: `/${cleanFolder}`, type: 'folder' },
|
||||
{ text: `/${exampleFile}`, type: 'file' },
|
||||
];
|
||||
// Insert prefix after bucket if present
|
||||
if (prefix) {
|
||||
parts.splice(2, 0, { text: `/${prefix}`, type: 'path' });
|
||||
}
|
||||
|
||||
return { parts };
|
||||
} else if (formData.source_type === 'local_folder') {
|
||||
if (formData.watch_folders.length === 0) return null;
|
||||
|
||||
return {
|
||||
parts: [
|
||||
{ text: firstFolder, type: 'folder' },
|
||||
{ text: `/${exampleFile}`, type: 'file' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// URL Preview Component
|
||||
const UrlPreviewBox = () => {
|
||||
const urlParts = buildExampleSyncUrl();
|
||||
|
||||
if (!urlParts) return null;
|
||||
|
||||
const getColorForType = (type: 'server' | 'path' | 'folder' | 'file') => {
|
||||
switch (type) {
|
||||
case 'server': return theme.palette.primary.main;
|
||||
case 'path': return theme.palette.info.main;
|
||||
case 'folder': return theme.palette.success.main;
|
||||
case 'file': return theme.palette.text.secondary;
|
||||
default: return theme.palette.text.primary;
|
||||
}
|
||||
};
|
||||
|
||||
const getLabelForType = (type: 'server' | 'path' | 'folder' | 'file') => {
|
||||
switch (type) {
|
||||
case 'server': return t('sources.urlPreview.legend.serverUrl');
|
||||
case 'path': return formData.source_type === 'webdav'
|
||||
? t('sources.urlPreview.legend.webdavPath')
|
||||
: t('sources.urlPreview.legend.bucketPrefix');
|
||||
case 'folder': return t('sources.urlPreview.legend.watchDirectory');
|
||||
case 'file': return t('sources.urlPreview.legend.exampleFile');
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique types for legend
|
||||
const uniqueTypes = Array.from(new Set(urlParts.parts.map(p => p.type)));
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha(theme.palette.background.default, 0.5),
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.3)}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
{t('sources.urlPreview.title')}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
wordBreak: 'break-all',
|
||||
p: 1.5,
|
||||
bgcolor: alpha(theme.palette.common.black, 0.02),
|
||||
borderRadius: 1,
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
{urlParts.parts.map((part, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
component="span"
|
||||
sx={{
|
||||
color: getColorForType(part.type),
|
||||
fontWeight: part.type === 'folder' ? 600 : 400,
|
||||
textDecoration: part.type === 'folder' ? 'underline' : 'none',
|
||||
textDecorationStyle: part.type === 'folder' ? 'dotted' : undefined,
|
||||
}}
|
||||
>
|
||||
{part.text}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
{uniqueTypes.map((type) => (
|
||||
<Stack key={type} direction="row" alignItems="center" spacing={0.5}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
bgcolor: getColorForType(type),
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{getLabelForType(type)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateSource = () => {
|
||||
setEditingSource(null);
|
||||
setFormData({
|
||||
|
|
@ -1799,7 +1635,7 @@ const SourcesPage: React.FC = () => {
|
|||
Folders to Monitor
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Specify which folders to scan for files. Use absolute paths starting with "/".
|
||||
</Typography>
|
||||
|
|
@ -1810,14 +1646,14 @@ const SourcesPage: React.FC = () => {
|
|||
value={newFolder}
|
||||
onChange={(e) => setNewFolder(e.target.value)}
|
||||
placeholder="/Documents"
|
||||
sx={{
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
'& .MuiOutlinedInput-root': { borderRadius: 2 }
|
||||
'& .MuiOutlinedInput-root': { borderRadius: 2 }
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={addFolder}
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={addFolder}
|
||||
disabled={!newFolder}
|
||||
sx={{ borderRadius: 2, px: 3 }}
|
||||
>
|
||||
|
|
@ -1831,8 +1667,8 @@ const SourcesPage: React.FC = () => {
|
|||
key={index}
|
||||
label={folder}
|
||||
onDelete={() => removeFolder(folder)}
|
||||
sx={{
|
||||
mr: 1,
|
||||
sx={{
|
||||
mr: 1,
|
||||
mb: 1,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha(theme.palette.secondary.main, 0.1),
|
||||
|
|
@ -1842,9 +1678,6 @@ const SourcesPage: React.FC = () => {
|
|||
))}
|
||||
</Box>
|
||||
|
||||
{/* URL Preview */}
|
||||
<UrlPreviewBox />
|
||||
|
||||
{/* File Extensions */}
|
||||
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
||||
<Avatar
|
||||
|
|
@ -2151,7 +1984,7 @@ const SourcesPage: React.FC = () => {
|
|||
Directories to Monitor
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Specify which local directories to scan for files. Use absolute paths.
|
||||
</Typography>
|
||||
|
|
@ -2162,14 +1995,14 @@ const SourcesPage: React.FC = () => {
|
|||
value={newFolder}
|
||||
onChange={(e) => setNewFolder(e.target.value)}
|
||||
placeholder="/home/user/Documents"
|
||||
sx={{
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
'& .MuiOutlinedInput-root': { borderRadius: 2 }
|
||||
'& .MuiOutlinedInput-root': { borderRadius: 2 }
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={addFolder}
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={addFolder}
|
||||
disabled={!newFolder}
|
||||
sx={{ borderRadius: 2, px: 3 }}
|
||||
>
|
||||
|
|
@ -2183,8 +2016,8 @@ const SourcesPage: React.FC = () => {
|
|||
key={index}
|
||||
label={folder}
|
||||
onDelete={() => removeFolder(folder)}
|
||||
sx={{
|
||||
mr: 1,
|
||||
sx={{
|
||||
mr: 1,
|
||||
mb: 1,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha(theme.palette.secondary.main, 0.1),
|
||||
|
|
@ -2194,13 +2027,10 @@ const SourcesPage: React.FC = () => {
|
|||
))}
|
||||
</Box>
|
||||
|
||||
{/* URL Preview */}
|
||||
<UrlPreviewBox />
|
||||
|
||||
{/* File Extensions */}
|
||||
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
||||
<Avatar
|
||||
sx={{
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: alpha(theme.palette.warning.main, 0.1),
|
||||
color: theme.palette.warning.main,
|
||||
width: 32,
|
||||
|
|
@ -2407,7 +2237,7 @@ const SourcesPage: React.FC = () => {
|
|||
Object Prefixes to Monitor
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Specify which object prefixes (like folders) to scan for files.
|
||||
</Typography>
|
||||
|
|
@ -2418,14 +2248,14 @@ const SourcesPage: React.FC = () => {
|
|||
value={newFolder}
|
||||
onChange={(e) => setNewFolder(e.target.value)}
|
||||
placeholder="documents/"
|
||||
sx={{
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
'& .MuiOutlinedInput-root': { borderRadius: 2 }
|
||||
'& .MuiOutlinedInput-root': { borderRadius: 2 }
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={addFolder}
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={addFolder}
|
||||
disabled={!newFolder}
|
||||
sx={{ borderRadius: 2, px: 3 }}
|
||||
>
|
||||
|
|
@ -2439,8 +2269,8 @@ const SourcesPage: React.FC = () => {
|
|||
key={index}
|
||||
label={folder}
|
||||
onDelete={() => removeFolder(folder)}
|
||||
sx={{
|
||||
mr: 1,
|
||||
sx={{
|
||||
mr: 1,
|
||||
mb: 1,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha(theme.palette.secondary.main, 0.1),
|
||||
|
|
@ -2450,13 +2280,10 @@ const SourcesPage: React.FC = () => {
|
|||
))}
|
||||
</Box>
|
||||
|
||||
{/* URL Preview */}
|
||||
<UrlPreviewBox />
|
||||
|
||||
{/* File Extensions */}
|
||||
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
|
||||
<Avatar
|
||||
sx={{
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: alpha(theme.palette.warning.main, 0.1),
|
||||
color: theme.palette.warning.main,
|
||||
width: 32,
|
||||
|
|
|
|||
|
|
@ -1,661 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* Unit tests for the URL Preview feature in SourcesPage
|
||||
*
|
||||
* The URL preview shows users an example of the sync URL that will be constructed
|
||||
* based on their inputs (server URL, server type, username, watch folders).
|
||||
*
|
||||
* This tests the URL construction logic that mirrors what the backend does
|
||||
* in src/services/webdav/config.rs
|
||||
*/
|
||||
|
||||
// Helper function that mirrors the buildExampleSyncUrl logic from SourcesPage
|
||||
// Extracted here for direct unit testing
|
||||
interface UrlPart {
|
||||
text: string;
|
||||
type: 'server' | 'path' | 'folder' | 'file';
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
source_type: 'webdav' | 'local_folder' | 's3';
|
||||
server_url: string;
|
||||
username: string;
|
||||
server_type: 'nextcloud' | 'owncloud' | 'generic';
|
||||
watch_folders: string[];
|
||||
bucket_name: string;
|
||||
region: string;
|
||||
endpoint_url: string;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
function buildExampleSyncUrl(formData: FormData): { parts: UrlPart[] } | null {
|
||||
const exampleFile = 'document1.pdf';
|
||||
const firstFolder = formData.watch_folders.length > 0 ? formData.watch_folders[0] : '/Documents';
|
||||
|
||||
if (formData.source_type === 'webdav') {
|
||||
if (!formData.server_url) return null;
|
||||
|
||||
let serverUrl = formData.server_url.trim();
|
||||
// Add https:// if no protocol specified
|
||||
if (!serverUrl.startsWith('http://') && !serverUrl.startsWith('https://')) {
|
||||
serverUrl = `https://${serverUrl}`;
|
||||
}
|
||||
serverUrl = serverUrl.replace(/\/+$/, ''); // Remove trailing slashes
|
||||
|
||||
let webdavPath = '';
|
||||
if (formData.server_type === 'nextcloud') {
|
||||
// Nextcloud uses /remote.php/dav/files/{username}
|
||||
if (!serverUrl.includes('/remote.php/dav/files/')) {
|
||||
webdavPath = `/remote.php/dav/files/${formData.username || 'username'}`;
|
||||
}
|
||||
} else if (formData.server_type === 'owncloud') {
|
||||
// ownCloud uses /remote.php/webdav
|
||||
if (!serverUrl.includes('/remote.php/webdav')) {
|
||||
webdavPath = '/remote.php/webdav';
|
||||
}
|
||||
}
|
||||
// For generic, use the URL as-is
|
||||
|
||||
const cleanFolder = firstFolder.replace(/^\/+/, ''); // Remove leading slashes
|
||||
|
||||
return {
|
||||
parts: [
|
||||
{ text: serverUrl, type: 'server' },
|
||||
{ text: webdavPath, type: 'path' },
|
||||
{ text: `/${cleanFolder}`, type: 'folder' },
|
||||
{ text: `/${exampleFile}`, type: 'file' },
|
||||
],
|
||||
};
|
||||
} else if (formData.source_type === 's3') {
|
||||
if (!formData.bucket_name) return null;
|
||||
|
||||
const endpoint = formData.endpoint_url?.trim() || `https://s3.${formData.region || 'us-east-1'}.amazonaws.com`;
|
||||
const cleanEndpoint = endpoint.replace(/\/+$/, '');
|
||||
const prefix = formData.prefix?.trim().replace(/^\/+|\/+$/g, '') || '';
|
||||
const cleanFolder = firstFolder.replace(/^\/+|\/+$/, '');
|
||||
|
||||
const parts: UrlPart[] = [
|
||||
{ text: cleanEndpoint, type: 'server' },
|
||||
{ text: `/${formData.bucket_name}`, type: 'path' },
|
||||
{ text: `/${cleanFolder}`, type: 'folder' },
|
||||
{ text: `/${exampleFile}`, type: 'file' },
|
||||
];
|
||||
// Insert prefix after bucket if present
|
||||
if (prefix) {
|
||||
parts.splice(2, 0, { text: `/${prefix}`, type: 'path' });
|
||||
}
|
||||
|
||||
return { parts };
|
||||
} else if (formData.source_type === 'local_folder') {
|
||||
if (formData.watch_folders.length === 0) return null;
|
||||
|
||||
return {
|
||||
parts: [
|
||||
{ text: firstFolder, type: 'folder' },
|
||||
{ text: `/${exampleFile}`, type: 'file' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to join URL parts into a single string for easier assertion
|
||||
function joinUrlParts(result: { parts: UrlPart[] } | null): string {
|
||||
if (!result) return '';
|
||||
return result.parts.map(p => p.text).join('');
|
||||
}
|
||||
|
||||
describe('SourcesPage URL Preview - WebDAV', () => {
|
||||
const baseWebdavForm: FormData = {
|
||||
source_type: 'webdav',
|
||||
server_url: '',
|
||||
username: '',
|
||||
server_type: 'generic',
|
||||
watch_folders: ['/Documents'],
|
||||
bucket_name: '',
|
||||
region: 'us-east-1',
|
||||
endpoint_url: '',
|
||||
prefix: '',
|
||||
};
|
||||
|
||||
describe('Nextcloud server type', () => {
|
||||
it('should construct URL with /remote.php/dav/files/{username} path', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://cloud.example.com',
|
||||
username: 'john',
|
||||
server_type: 'nextcloud',
|
||||
watch_folders: ['/Documents'],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toBe('https://cloud.example.com/remote.php/dav/files/john/Documents/document1.pdf');
|
||||
});
|
||||
|
||||
it('should use "username" placeholder when username is empty', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://cloud.example.com',
|
||||
username: '',
|
||||
server_type: 'nextcloud',
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toContain('/remote.php/dav/files/username/');
|
||||
});
|
||||
|
||||
it('should not duplicate path if URL already contains /remote.php/dav/files/', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://cloud.example.com/remote.php/dav/files/john',
|
||||
username: 'john',
|
||||
server_type: 'nextcloud',
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
|
||||
// Should not have double /remote.php/dav/files/
|
||||
expect(result?.parts.filter(p => p.text.includes('/remote.php/dav/files/')).length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should handle server URL without trailing slash', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://cloud.example.com',
|
||||
username: 'john',
|
||||
server_type: 'nextcloud',
|
||||
};
|
||||
|
||||
const url = joinUrlParts(buildExampleSyncUrl(formData));
|
||||
|
||||
// Should not have double slashes (except in https://)
|
||||
expect(url.replace('https://', '')).not.toContain('//');
|
||||
});
|
||||
|
||||
it('should handle server URL with trailing slash', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://cloud.example.com/',
|
||||
username: 'john',
|
||||
server_type: 'nextcloud',
|
||||
};
|
||||
|
||||
const url = joinUrlParts(buildExampleSyncUrl(formData));
|
||||
|
||||
// Should not have double slashes
|
||||
expect(url.replace('https://', '')).not.toContain('//');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ownCloud server type', () => {
|
||||
it('should construct URL with /remote.php/webdav path', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://owncloud.example.com',
|
||||
username: 'john',
|
||||
server_type: 'owncloud',
|
||||
watch_folders: ['/Documents'],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toBe('https://owncloud.example.com/remote.php/webdav/Documents/document1.pdf');
|
||||
});
|
||||
|
||||
it('should not duplicate path if URL already contains /remote.php/webdav', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://owncloud.example.com/remote.php/webdav',
|
||||
username: 'john',
|
||||
server_type: 'owncloud',
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
|
||||
// Should not have double /remote.php/webdav
|
||||
expect(result?.parts.filter(p => p.text.includes('/remote.php/webdav')).length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generic WebDAV server type', () => {
|
||||
it('should use server URL as-is without adding WebDAV path', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://webdav.example.com/dav',
|
||||
username: 'john',
|
||||
server_type: 'generic',
|
||||
watch_folders: ['/Documents'],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toBe('https://webdav.example.com/dav/Documents/document1.pdf');
|
||||
});
|
||||
|
||||
it('should not add /remote.php paths for generic servers', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://custom.webdav.com',
|
||||
server_type: 'generic',
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).not.toContain('/remote.php');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Protocol handling', () => {
|
||||
it('should add https:// when no protocol is specified', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'cloud.example.com',
|
||||
server_type: 'generic',
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toStartWith('https://');
|
||||
});
|
||||
|
||||
it('should preserve http:// when explicitly specified', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'http://local.webdav.com',
|
||||
server_type: 'generic',
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toStartWith('http://');
|
||||
expect(url).not.toStartWith('https://');
|
||||
});
|
||||
|
||||
it('should preserve https:// when explicitly specified', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://secure.webdav.com',
|
||||
server_type: 'generic',
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toStartWith('https://');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Watch folder handling', () => {
|
||||
it('should use first watch folder in the URL', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://webdav.example.com',
|
||||
server_type: 'generic',
|
||||
watch_folders: ['/Photos', '/Documents', '/Videos'],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toContain('/Photos/');
|
||||
expect(url).not.toContain('/Documents/');
|
||||
});
|
||||
|
||||
it('should handle watch folder with leading slash', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://webdav.example.com',
|
||||
server_type: 'generic',
|
||||
watch_folders: ['/Documents'],
|
||||
};
|
||||
|
||||
const url = joinUrlParts(buildExampleSyncUrl(formData));
|
||||
|
||||
// Should not have double slashes around folder
|
||||
expect(url).toContain('/Documents/');
|
||||
expect(url.replace('https://', '')).not.toContain('//');
|
||||
});
|
||||
|
||||
it('should handle watch folder without leading slash', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://webdav.example.com',
|
||||
server_type: 'generic',
|
||||
watch_folders: ['Documents'],
|
||||
};
|
||||
|
||||
const url = joinUrlParts(buildExampleSyncUrl(formData));
|
||||
|
||||
expect(url).toContain('/Documents/');
|
||||
});
|
||||
|
||||
it('should default to /Documents when watch_folders is empty', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: 'https://webdav.example.com',
|
||||
server_type: 'generic',
|
||||
watch_folders: [],
|
||||
};
|
||||
|
||||
const url = joinUrlParts(buildExampleSyncUrl(formData));
|
||||
|
||||
expect(url).toContain('/Documents/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should return null when server_url is empty', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: '',
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle whitespace in server_url', () => {
|
||||
const formData: FormData = {
|
||||
...baseWebdavForm,
|
||||
server_url: ' https://webdav.example.com ',
|
||||
server_type: 'generic',
|
||||
};
|
||||
|
||||
const url = joinUrlParts(buildExampleSyncUrl(formData));
|
||||
|
||||
expect(url).toStartWith('https://');
|
||||
expect(url).not.toContain(' ');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SourcesPage URL Preview - S3', () => {
|
||||
const baseS3Form: FormData = {
|
||||
source_type: 's3',
|
||||
server_url: '',
|
||||
username: '',
|
||||
server_type: 'generic',
|
||||
watch_folders: ['/documents'],
|
||||
bucket_name: '',
|
||||
region: 'us-east-1',
|
||||
endpoint_url: '',
|
||||
prefix: '',
|
||||
};
|
||||
|
||||
describe('AWS S3', () => {
|
||||
it('should construct URL with default AWS endpoint when endpoint_url is empty', () => {
|
||||
const formData: FormData = {
|
||||
...baseS3Form,
|
||||
bucket_name: 'my-bucket',
|
||||
region: 'us-west-2',
|
||||
watch_folders: ['/documents'],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toBe('https://s3.us-west-2.amazonaws.com/my-bucket/documents/document1.pdf');
|
||||
});
|
||||
|
||||
it('should use us-east-1 as default region', () => {
|
||||
const formData: FormData = {
|
||||
...baseS3Form,
|
||||
bucket_name: 'my-bucket',
|
||||
region: '',
|
||||
watch_folders: ['/documents'],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toContain('s3.us-east-1.amazonaws.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3-compatible storage (MinIO)', () => {
|
||||
it('should use custom endpoint_url when provided', () => {
|
||||
const formData: FormData = {
|
||||
...baseS3Form,
|
||||
bucket_name: 'my-bucket',
|
||||
endpoint_url: 'https://minio.example.com',
|
||||
watch_folders: ['/documents'],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toBe('https://minio.example.com/my-bucket/documents/document1.pdf');
|
||||
});
|
||||
|
||||
it('should handle endpoint_url with trailing slash', () => {
|
||||
const formData: FormData = {
|
||||
...baseS3Form,
|
||||
bucket_name: 'my-bucket',
|
||||
endpoint_url: 'https://minio.example.com/',
|
||||
watch_folders: ['/documents'],
|
||||
};
|
||||
|
||||
const url = joinUrlParts(buildExampleSyncUrl(formData));
|
||||
|
||||
// Should not have double slashes
|
||||
expect(url.replace('https://', '')).not.toContain('//');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prefix handling', () => {
|
||||
it('should include prefix in URL when provided', () => {
|
||||
const formData: FormData = {
|
||||
...baseS3Form,
|
||||
bucket_name: 'my-bucket',
|
||||
prefix: 'uploads/2024',
|
||||
watch_folders: ['/documents'],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toContain('/uploads/2024/');
|
||||
});
|
||||
|
||||
it('should handle prefix with leading/trailing slashes', () => {
|
||||
const formData: FormData = {
|
||||
...baseS3Form,
|
||||
bucket_name: 'my-bucket',
|
||||
prefix: '/uploads/2024/',
|
||||
watch_folders: ['/documents'],
|
||||
};
|
||||
|
||||
const url = joinUrlParts(buildExampleSyncUrl(formData));
|
||||
|
||||
// Should normalize slashes
|
||||
expect(url.replace('https://', '')).not.toContain('//');
|
||||
});
|
||||
|
||||
it('should not include prefix segment when prefix is empty', () => {
|
||||
const formData: FormData = {
|
||||
...baseS3Form,
|
||||
bucket_name: 'my-bucket',
|
||||
prefix: '',
|
||||
watch_folders: ['/documents'],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
|
||||
// Should only have 4 parts: server, bucket, folder, file (no prefix)
|
||||
expect(result?.parts.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Part types', () => {
|
||||
it('should correctly type each URL part', () => {
|
||||
const formData: FormData = {
|
||||
...baseS3Form,
|
||||
bucket_name: 'my-bucket',
|
||||
endpoint_url: 'https://s3.example.com',
|
||||
prefix: 'prefix',
|
||||
watch_folders: ['/documents'],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
|
||||
expect(result?.parts[0].type).toBe('server'); // endpoint
|
||||
expect(result?.parts[1].type).toBe('path'); // bucket
|
||||
expect(result?.parts[2].type).toBe('path'); // prefix
|
||||
expect(result?.parts[3].type).toBe('folder'); // watch folder
|
||||
expect(result?.parts[4].type).toBe('file'); // example file
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should return null when bucket_name is empty', () => {
|
||||
const formData: FormData = {
|
||||
...baseS3Form,
|
||||
bucket_name: '',
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SourcesPage URL Preview - Local Folder', () => {
|
||||
const baseLocalForm: FormData = {
|
||||
source_type: 'local_folder',
|
||||
server_url: '',
|
||||
username: '',
|
||||
server_type: 'generic',
|
||||
watch_folders: [],
|
||||
bucket_name: '',
|
||||
region: 'us-east-1',
|
||||
endpoint_url: '',
|
||||
prefix: '',
|
||||
};
|
||||
|
||||
it('should show local path with example file', () => {
|
||||
const formData: FormData = {
|
||||
...baseLocalForm,
|
||||
watch_folders: ['/home/user/Documents'],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toBe('/home/user/Documents/document1.pdf');
|
||||
});
|
||||
|
||||
it('should use first watch folder', () => {
|
||||
const formData: FormData = {
|
||||
...baseLocalForm,
|
||||
watch_folders: ['/var/data', '/home/user/Documents'],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
const url = joinUrlParts(result);
|
||||
|
||||
expect(url).toContain('/var/data/');
|
||||
expect(url).not.toContain('/home/user/Documents');
|
||||
});
|
||||
|
||||
it('should return null when watch_folders is empty', () => {
|
||||
const formData: FormData = {
|
||||
...baseLocalForm,
|
||||
watch_folders: [],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should correctly type parts for local folder', () => {
|
||||
const formData: FormData = {
|
||||
...baseLocalForm,
|
||||
watch_folders: ['/home/user/Documents'],
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
|
||||
expect(result?.parts.length).toBe(2);
|
||||
expect(result?.parts[0].type).toBe('folder');
|
||||
expect(result?.parts[1].type).toBe('file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SourcesPage URL Preview - URL Part Types', () => {
|
||||
it('WebDAV should have correct part types', () => {
|
||||
const formData: FormData = {
|
||||
source_type: 'webdav',
|
||||
server_url: 'https://cloud.example.com',
|
||||
username: 'john',
|
||||
server_type: 'nextcloud',
|
||||
watch_folders: ['/Documents'],
|
||||
bucket_name: '',
|
||||
region: 'us-east-1',
|
||||
endpoint_url: '',
|
||||
prefix: '',
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
|
||||
expect(result?.parts[0].type).toBe('server'); // https://cloud.example.com
|
||||
expect(result?.parts[1].type).toBe('path'); // /remote.php/dav/files/john
|
||||
expect(result?.parts[2].type).toBe('folder'); // /Documents
|
||||
expect(result?.parts[3].type).toBe('file'); // /document1.pdf
|
||||
});
|
||||
|
||||
it('Generic WebDAV should have empty path part', () => {
|
||||
const formData: FormData = {
|
||||
source_type: 'webdav',
|
||||
server_url: 'https://webdav.example.com',
|
||||
username: 'john',
|
||||
server_type: 'generic',
|
||||
watch_folders: ['/Documents'],
|
||||
bucket_name: '',
|
||||
region: 'us-east-1',
|
||||
endpoint_url: '',
|
||||
prefix: '',
|
||||
};
|
||||
|
||||
const result = buildExampleSyncUrl(formData);
|
||||
|
||||
// For generic, webdavPath is empty string
|
||||
expect(result?.parts[1].text).toBe('');
|
||||
expect(result?.parts[1].type).toBe('path');
|
||||
});
|
||||
});
|
||||
|
||||
// Custom matcher for startsWith
|
||||
expect.extend({
|
||||
toStartWith(received: string, expected: string) {
|
||||
const pass = received.startsWith(expected);
|
||||
return {
|
||||
message: () =>
|
||||
pass
|
||||
? `expected ${received} not to start with ${expected}`
|
||||
: `expected ${received} to start with ${expected}`,
|
||||
pass,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
declare global {
|
||||
namespace jest {
|
||||
interface Matchers<R> {
|
||||
toStartWith(expected: string): R;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Creates a matchMedia mock that can be configured for different query responses
|
||||
* @param standaloneMode - Whether to simulate PWA standalone mode
|
||||
* @returns Mock implementation of window.matchMedia
|
||||
*/
|
||||
export const createMatchMediaMock = (standaloneMode: boolean = false) => {
|
||||
return vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes('standalone') ? standaloneMode : false,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(), // Deprecated but still supported
|
||||
removeListener: vi.fn(), // Deprecated but still supported
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up window.matchMedia to simulate PWA standalone mode
|
||||
* @param enabled - Whether PWA mode should be enabled (default: true)
|
||||
*/
|
||||
export const setupPWAMode = (enabled: boolean = true) => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: createMatchMediaMock(enabled),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets up iOS-specific PWA detection via navigator.standalone
|
||||
* @param enabled - Whether iOS PWA mode should be enabled (default: true)
|
||||
*/
|
||||
export const setupIOSPWAMode = (enabled: boolean = true) => {
|
||||
Object.defineProperty(window.navigator, 'standalone', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: enabled,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets PWA-related window properties to their default state
|
||||
* Useful for cleanup between tests
|
||||
*/
|
||||
export const resetPWAMocks = () => {
|
||||
// Reset matchMedia to default non-PWA state
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: createMatchMediaMock(false),
|
||||
});
|
||||
|
||||
// Reset iOS standalone if it exists
|
||||
if ('standalone' in window.navigator) {
|
||||
Object.defineProperty(window.navigator, 'standalone', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a matchMedia mock that supports multiple query patterns
|
||||
* @param queries - Map of query patterns to their match states
|
||||
* @returns Mock implementation that responds to different queries
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const mockFn = createResponsiveMatchMediaMock({
|
||||
* 'standalone': true, // PWA mode
|
||||
* 'max-width: 900px': true, // Mobile
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const createResponsiveMatchMediaMock = (
|
||||
queries: Record<string, boolean>
|
||||
) => {
|
||||
return vi.fn().mockImplementation((query: string) => {
|
||||
// Check if any of the query patterns match the input query
|
||||
const matches = Object.entries(queries).some(([pattern, shouldMatch]) =>
|
||||
query.includes(pattern) ? shouldMatch : false
|
||||
);
|
||||
|
||||
return {
|
||||
matches,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
|
@ -6,7 +6,6 @@ import { I18nextProvider } from 'react-i18next'
|
|||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import { NotificationProvider } from '../contexts/NotificationContext'
|
||||
import { createMatchMediaMock, createResponsiveMatchMediaMock } from './pwa-test-utils'
|
||||
|
||||
// Initialize i18n for tests
|
||||
i18n
|
||||
|
|
@ -247,77 +246,6 @@ export const renderWithAdminUser = (
|
|||
return renderWithAuthenticatedUser(ui, createMockAdminUser(), options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders component with PWA mode enabled
|
||||
* Sets up window.matchMedia to simulate standalone display mode
|
||||
*/
|
||||
export const renderWithPWA = (
|
||||
ui: React.ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'> & {
|
||||
authValues?: Partial<MockAuthContextType>
|
||||
routerProps?: any
|
||||
}
|
||||
) => {
|
||||
// Set up matchMedia to return true for standalone mode
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: createMatchMediaMock(true),
|
||||
})
|
||||
|
||||
return renderWithProviders(ui, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders component with mobile viewport simulation
|
||||
* Mocks useMediaQuery to return true for mobile breakpoints
|
||||
*/
|
||||
export const renderWithMobile = (
|
||||
ui: React.ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'> & {
|
||||
authValues?: Partial<MockAuthContextType>
|
||||
routerProps?: any
|
||||
}
|
||||
) => {
|
||||
// Set up matchMedia to simulate mobile viewport (max-width: 900px)
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: createResponsiveMatchMediaMock({
|
||||
'(max-width: 900px)': true,
|
||||
'(max-width:900px)': true, // Without spaces variant
|
||||
}),
|
||||
})
|
||||
|
||||
return renderWithProviders(ui, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders component with both PWA mode and mobile viewport
|
||||
* Combines PWA standalone mode with mobile breakpoint simulation
|
||||
*/
|
||||
export const renderWithPWAMobile = (
|
||||
ui: React.ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'> & {
|
||||
authValues?: Partial<MockAuthContextType>
|
||||
routerProps?: any
|
||||
}
|
||||
) => {
|
||||
// Set up matchMedia to handle both PWA and mobile queries
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: createResponsiveMatchMediaMock({
|
||||
'standalone': true,
|
||||
'(display-mode: standalone)': true,
|
||||
'(max-width: 900px)': true,
|
||||
'(max-width:900px)': true,
|
||||
}),
|
||||
})
|
||||
|
||||
return renderWithProviders(ui, options)
|
||||
}
|
||||
|
||||
// Mock localStorage consistently across tests
|
||||
export const createMockLocalStorage = () => {
|
||||
const storage: Record<string, string> = {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue