Compare commits

...

21 Commits

Author SHA1 Message Date
Alex fcf7905a65
Merge pull request #387 from readur/feat-pwa-support
feat: PWA support take 2
2025-12-11 20:11:28 -08:00
aaldebs99 d5fc4bd4be feat(test): add PWA tests 2025-12-12 03:16:04 +00:00
aaldebs99 5cfa8748d5 fix(frontend): race condition in label create dialog 2025-12-12 02:19:42 +00:00
Alex 2efba6ec2c
Merge pull request #388 from readur/react2shell-hard-patch
fix(frontend): set react minimum to CVE-2025-55182 patched version
2025-12-11 18:08:01 -08:00
renovate[bot] ee83dbb2c7
chore(deps): update dependency i18next to v25.7.2 (#382)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 01:21:39 +00:00
aaldebs99 2a8c8febab fix(frontend): set react minimum to CVE-2025-55182 patched version 2025-12-12 01:20:21 +00:00
aaldebs99 18072b615b feat(pwa): make settings scrollable, scaling improvements 2025-12-12 01:04:44 +00:00
aaldebs99 41b2bad3c7 feat(pwa): replace search button with tags, improve scaling 2025-12-12 00:52:06 +00:00
aaldebs99 3a18e17ece feat(pwa): better PWA detection 2025-12-12 00:36:42 +00:00
Alex 0df61038a8
chore: PWA branch update 2025-12-11 15:54:31 -08:00
aaldebs99 0c56c9e816 Revert "Merge branch 'feat-pwa-support' into main"
This reverts commit 90a4892a18, reversing
changes made to bd1f7e469e.
2025-12-11 23:52:11 +00:00
Alex 90a4892a18
Merge branch 'feat-pwa-support' into main 2025-12-11 15:45:28 -08:00
perfectra1n bd1f7e469e feat(translations): use i18n for url previews 2025-12-11 13:03:29 -08:00
Jon Fuller 55f953bca1
Merge pull request #384 from readur/feat/show-sync-url-preview
feat(ui): show in the UI the sync URL that would be hit
2025-12-11 12:56:32 -08:00
perfectra1n 6276af3c7d feat(ui): add url tests as well 2025-12-11 12:39:50 -08:00
perfectra1n dff8165adb fix(ui): resolve missing brace in translation json 2025-12-11 08:53:15 -08:00
perfectra1n 76e4562f77 feat(ui): show in the UI the sync URL that would be hit 2025-12-11 08:49:55 -08:00
renovate[bot] 8cd859d38e
chore(deps): update dependency @vitejs/plugin-react to v5.1.2 (#381)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 14:48:35 +00:00
renovate[bot] a8f3424520
chore(deps): update dependency vite to v7.2.7 (#380)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 09:49:13 +00:00
perfectra1n 8960ae0ff4 feat(docs): update docs about auto-generated admin password 2025-12-10 13:54:02 -08:00
aaldebs99 4a80470a41 Feat(UI): initial PWA implementation 2025-10-30 23:52:36 +00:00
26 changed files with 2034 additions and 140 deletions

View File

@ -42,11 +42,27 @@ docker compose up --build -d
open http://localhost:8000
```
**Default login credentials:**
**Admin credentials:**
- Username: `admin`
- Password: `readur2024`
- Password: Auto-generated on first run (check container logs)
> ⚠️ **Important**: Change the default admin password immediately after first login!
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`
## 📚 Documentation

View File

@ -225,17 +225,20 @@ docker logs -f readur
http://localhost:8000
```
2. **Login with Default Credentials**
2. **Login with Admin Credentials**
- Username: `admin`
- Password: `readur2024`
⚠️ **Security**: Change the admin password immediately after first login
- Password: Check the container logs for your auto-generated password
3. **Change Admin Password**
- Navigate to Settings → User Management
- Click on admin user
- Set a strong password
- Save changes
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
```
### Essential Configuration
@ -484,10 +487,10 @@ deploy:
Upload your first document:
```bash
# 1. Login to get token
# 1. Login to get token (use your generated password from the logs)
TOKEN=$(curl -s -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"readur2024"}' | jq -r .token)
-d '{"username":"admin","password":"YOUR_GENERATED_PASSWORD"}' | jq -r .token)
# 2. Upload a PDF
curl -X POST http://localhost:8000/api/documents/upload \

View File

@ -39,9 +39,11 @@ Open your browser and navigate to:
http://localhost:8000
```
Login with default credentials:
Login with your admin credentials:
- **Username**: `admin`
- **Password**: `readur2024`
- **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.
### Step 4: Upload Your First Document
@ -54,10 +56,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
# Authenticate and get a session token (use your generated password from the logs)
TOKEN=$(curl -s -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"readur2024"}' | jq -r .token)
-d '{"username":"admin","password":"YOUR_GENERATED_PASSWORD"}' | jq -r .token)
# Upload a document using the API
curl -X POST http://localhost:8000/api/documents/upload \
@ -73,9 +75,15 @@ Once the OCR indicator shows green (processing complete), you can test Readur's
## Common First Tasks
### Change Admin Password
### Resetting Admin Password
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.
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.
### Add Your First Source

View File

@ -90,7 +90,7 @@ docker-compose up -d
open http://localhost:8000
```
Log in with the default credentials (admin / readur2024) and change the password immediately. Then upload a document and watch Readur extract the text automatically.
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.
## How People Use Readur

View File

@ -28,11 +28,27 @@ docker compose up --build -d
open http://localhost:8000
```
**Default login credentials:**
**Admin credentials:**
- Username: `admin`
- Password: `readur2024`
- Password: Auto-generated on first run (check container logs)
> ⚠️ **Important**: Change the default admin password immediately after first login!
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`
### What You Get

View File

@ -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 default admin credentials: username `admin` and password `readur2024`. The very first thing you should do is change this default password to something secure.
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.
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.

View File

@ -104,7 +104,7 @@ See the [OIDC Setup Guide](oidc-setup.md) for detailed configuration instruction
**Default Admin Account:**
- Username: `admin`
- Default Password: `readur2024` ⚠️ **Change immediately in production!**
- Password: Auto-generated on first startup (check container logs for "READUR ADMIN USER CREATED")
## Admin User Management

View File

@ -22,8 +22,8 @@
"i18next": "^25.5.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"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.0.0",
"@types/react-dom": "^19.0.0",
"@types/react": "^19.2.1",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.14",
"jsdom": "^26.1.0",
@ -105,7 +105,6 @@
"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",
@ -452,7 +451,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -476,7 +474,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -539,7 +536,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -583,7 +579,6 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -1194,7 +1189,6 @@
"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",
@ -1305,7 +1299,6 @@
"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",
@ -1503,9 +1496,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"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==",
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
"integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==",
"dev": true,
"license": "MIT"
},
@ -1884,7 +1877,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@ -1936,8 +1930,7 @@
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/chai-subset": {
"version": "1.3.6",
@ -1962,7 +1955,6 @@
"integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.8.0"
}
@ -1984,7 +1976,6 @@
"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"
}
@ -1995,7 +1986,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -2020,16 +2010,16 @@
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz",
"integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
"integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==",
"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.47",
"@rolldown/pluginutils": "1.0.0-beta.53",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.18.0"
},
@ -2140,6 +2130,7 @@
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@ -2279,7 +2270,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@ -2379,6 +2369,7 @@
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@ -2435,6 +2426,7 @@
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"color-name": "~1.1.4"
},
@ -2447,7 +2439,8 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
@ -2563,7 +2556,6 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@ -2640,7 +2632,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dom-helpers": {
"version": "5.2.1",
@ -3008,6 +3001,7 @@
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@ -3117,9 +3111,9 @@
}
},
"node_modules/i18next": {
"version": "25.7.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.1.tgz",
"integrity": "sha512-XbTnkh1yCZWSAZGnA9xcQfHcYNgZs2cNxm+c6v1Ma9UAUGCeJPplRe1ILia6xnDvXBjk0uXU+Z8FYWhA19SKFw==",
"version": "25.7.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.2.tgz",
"integrity": "sha512-58b4kmLpLv1buWUEwegMDUqZVR5J+rT+WTRFaBGL7lxDuJQQ0NrJFrq+eT2N94aYVR1k1Sr13QITNOL88tZCuw==",
"funding": [
{
"type": "individual",
@ -3135,7 +3129,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@ -3245,7 +3238,6 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@ -3368,6 +3360,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@ -3635,7 +3628,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -3714,7 +3706,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -3804,7 +3795,6 @@
"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"
}
@ -3814,7 +3804,6 @@
"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"
},
@ -4279,6 +4268,7 @@
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"has-flag": "^4.0.0"
},
@ -4424,7 +4414,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -4501,12 +4490,11 @@
}
},
"node_modules/vite": {
"version": "7.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz",
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"version": "7.2.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",

View File

@ -33,8 +33,8 @@
"i18next": "^25.5.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"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.0.0",
"@types/react-dom": "^19.0.0",
"@types/react": "^19.2.1",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.14",
"jsdom": "^26.1.0",

View File

@ -1489,6 +1489,16 @@
"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"
}
}
}
}

View File

@ -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,6 +1489,16 @@
"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"
}
}
}
}

View File

@ -1489,6 +1489,16 @@
"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"
}
}
}
}

View File

@ -1489,6 +1489,16 @@
"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"
}
}
}
}

View File

@ -367,7 +367,11 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
}}
sx={{
width: '100%',
minWidth: 600,
minWidth: {
xs: '200px', // Mobile: minimum viable width
sm: '400px', // Small tablets
md: 600, // Desktop: original size
},
maxWidth: 1200,
'& .MuiOutlinedInput-root': {
background: theme.palette.mode === 'light'

View File

@ -130,7 +130,9 @@ const LabelCreateDialog: React.FC<LabelCreateDialogProps> = ({
background_color: formData.background_color || undefined,
icon: formData.icon || undefined,
});
handleClose();
// Call onClose directly after successful submission
// Don't use handleClose() here to avoid race conditions with loading state
onClose();
} catch (error) {
console.error('Failed to save label:', error);
// Could add error handling UI here

View File

@ -46,6 +46,8 @@ 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;
@ -80,6 +82,7 @@ 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);
@ -438,6 +441,7 @@ 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%)',
@ -452,15 +456,24 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
</Typography>
{/* Global Search Bar */}
<Box sx={{ flexGrow: 2, display: 'flex', justifyContent: 'center', mx: 1, flex: '1 1 auto' }}>
<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',
}}>
<GlobalSearchBar />
</Box>
{/* Notifications */}
<IconButton
<IconButton
onClick={handleNotificationClick}
sx={{
mr: 2,
sx={{
mr: { xs: 1, md: 2 },
display: isPWA ? 'none' : 'flex',
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%)'
@ -480,8 +493,8 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
},
}}
>
<Badge
badgeContent={unreadCount}
<Badge
badgeContent={unreadCount}
sx={{
'& .MuiBadge-badge': {
background: 'linear-gradient(135deg, #ef4444 0%, #f97316 100%)',
@ -497,7 +510,8 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
{/* Language Switcher */}
<Box sx={{
mr: 2,
mr: { xs: 1, md: 2 },
display: isPWA ? 'none' : { xs: 'none', sm: 'block' },
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%)',
@ -518,7 +532,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
{/* Theme Toggle */}
<Box sx={{
mr: 2,
mr: { xs: 1, md: 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%)',
@ -660,16 +674,23 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
}}
>
<Toolbar />
<Box sx={{ p: 3 }}>
<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,
}}>
{children}
</Box>
</Box>
{/* Notification Panel */}
<NotificationPanel
anchorEl={notificationAnchorEl}
onClose={handleNotificationClose}
<NotificationPanel
anchorEl={notificationAnchorEl}
onClose={handleNotificationClose}
/>
{/* Bottom Navigation (PWA only) */}
<BottomNavigation />
</Box>
);
};

View File

@ -0,0 +1,192 @@
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;

View File

@ -0,0 +1,265 @@
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);
});
});
});

View File

@ -0,0 +1,250 @@
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);
});
});
});

View File

@ -0,0 +1,31 @@
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;
};

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
@ -108,10 +108,11 @@ 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);
@ -140,24 +141,57 @@ 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]);
}, [pagination?.limit, pagination?.offset, ocrFilter, debouncedSearchQuery]);
const fetchDocuments = async (): Promise<void> => {
if (!pagination) return;
try {
setLoading(true);
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 });
// 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 });
}
} catch (err) {
setError(t('common.status.error'));
console.error(err);
@ -263,12 +297,9 @@ const DocumentsPage: React.FC = () => {
});
};
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) => {
// 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) => {
let aValue: any = a[sortBy];
let bValue: any = b[sortBy];

View File

@ -47,6 +47,7 @@ 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 {
@ -194,6 +195,7 @@ 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',
@ -837,20 +839,41 @@ const SettingsPage: React.FC = () => {
};
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Typography variant="h4" sx={{ mb: 4 }}>
<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 }}>
{t('settings.title')}
</Typography>
<Paper sx={{ width: '100%' }}>
<Tabs value={tabValue} onChange={handleTabChange} aria-label="settings tabs">
<Tabs
value={tabValue}
onChange={handleTabChange}
aria-label="settings tabs"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{
'& .MuiTabs-scrollButtons': {
'&.Mui-disabled': {
opacity: 0.3,
},
},
}}
>
<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: 3 }}>
<Box sx={{ p: { xs: 2, sm: 3 } }}>
{tabValue === 0 && (
<Box>
<Typography variant="h6" sx={{ mb: 3 }}>

View File

@ -279,6 +279,170 @@ 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({
@ -1635,7 +1799,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>
@ -1646,14 +1810,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 }}
>
@ -1667,8 +1831,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),
@ -1678,6 +1842,9 @@ const SourcesPage: React.FC = () => {
))}
</Box>
{/* URL Preview */}
<UrlPreviewBox />
{/* File Extensions */}
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
<Avatar
@ -1984,7 +2151,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>
@ -1995,14 +2162,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 }}
>
@ -2016,8 +2183,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),
@ -2027,10 +2194,13 @@ 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,
@ -2237,7 +2407,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>
@ -2248,14 +2418,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 }}
>
@ -2269,8 +2439,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),
@ -2280,10 +2450,13 @@ 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,

View File

@ -0,0 +1,661 @@
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;
}
}
}

View File

@ -0,0 +1,98 @@
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(),
};
});
};

View File

@ -6,6 +6,7 @@ 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
@ -246,6 +247,77 @@ 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> = {}