Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot] f6e9d5c90e
chore(deps): update actions/cache action to v5 2025-12-11 23:46:37 +00:00
25 changed files with 777 additions and 894 deletions

View File

@ -113,7 +113,7 @@ jobs:
components: rustfmt, clippy
- name: Cache cargo registry
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
@ -123,7 +123,7 @@ jobs:
${{ runner.os }}-cargo-stress-
- name: Cache target directory
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: target
key: ${{ runner.os }}-cargo-target-stress-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('**/*.rs') }}

View File

@ -3,7 +3,24 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Primary Meta Tags -->
<meta name="description" content="AI-powered document management with OCR and intelligent search" />
<meta name="theme-color" content="#6366f1" />
<!-- iOS PWA Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Readur" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<!-- Android PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">

View File

@ -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",
@ -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"
}
@ -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"
@ -4495,6 +4506,7 @@
"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.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",
@ -55,6 +55,8 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.8.3",
"vite": "^7.0.0",
"vitest": "^0.28.0"
"vite-plugin-pwa": "^1.1.0",
"vitest": "^0.28.0",
"workbox-window": "^7.3.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

View File

@ -0,0 +1,77 @@
{
"name": "Readur - Document Intelligence Platform",
"short_name": "Readur",
"description": "AI-powered document management with OCR and intelligent search",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#6366f1",
"orientation": "portrait-primary",
"icons": [
{
"src": "/readur-32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/readur-64.png",
"sizes": "64x64",
"type": "image/png"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"categories": ["productivity", "utilities", "business"],
"shortcuts": [
{
"name": "Upload Document",
"short_name": "Upload",
"description": "Upload a new document",
"url": "/upload",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192"
}
]
},
{
"name": "Search Documents",
"short_name": "Search",
"description": "Search your documents",
"url": "/search",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192"
}
]
}
],
"screenshots": [],
"display_override": ["standalone", "minimal-ui"],
"prefer_related_applications": false
}

View File

@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Offline - Readur</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: env(safe-area-inset-top, 20px) env(safe-area-inset-right, 20px) env(safe-area-inset-bottom, 20px) env(safe-area-inset-left, 20px);
}
.container {
text-align: center;
max-width: 500px;
padding: 40px 20px;
}
.icon {
width: 120px;
height: 120px;
margin: 0 auto 30px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.icon svg {
width: 60px;
height: 60px;
stroke: #fff;
stroke-width: 2;
fill: none;
}
h1 {
font-size: 32px;
font-weight: 700;
margin-bottom: 16px;
letter-spacing: -0.5px;
}
p {
font-size: 18px;
line-height: 1.6;
margin-bottom: 32px;
opacity: 0.9;
}
.status {
background: rgba(255, 255, 255, 0.15);
border-radius: 12px;
padding: 16px 24px;
margin-bottom: 32px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.status-text {
font-size: 14px;
opacity: 0.8;
}
.retry-button {
background: #fff;
color: #667eea;
border: none;
border-radius: 12px;
padding: 16px 32px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.retry-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 25px rgba(0, 0, 0, 0.3);
}
.retry-button:active {
transform: translateY(0);
}
@media (max-width: 480px) {
h1 {
font-size: 28px;
}
p {
font-size: 16px;
}
.icon {
width: 100px;
height: 100px;
}
.icon svg {
width: 50px;
height: 50px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="icon">
<svg viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z" />
<line x1="2" y1="2" x2="22" y2="22" stroke-linecap="round" />
</svg>
</div>
<h1>You're Offline</h1>
<p>It looks like you've lost your internet connection. Don't worry, Readur will be back once you're online again.</p>
<div class="status">
<p class="status-text" id="status">Checking connection...</p>
</div>
<button class="retry-button" onclick="window.location.reload()">
Try Again
</button>
</div>
<script>
function updateConnectionStatus() {
const statusEl = document.getElementById('status');
if (navigator.onLine) {
statusEl.textContent = 'Connection restored! Click "Try Again" to continue.';
} else {
statusEl.textContent = 'No internet connection detected.';
}
}
// Update status immediately
updateConnectionStatus();
// Listen for online/offline events
window.addEventListener('online', updateConnectionStatus);
window.addEventListener('offline', updateConnectionStatus);
// Auto-reload when connection is restored
window.addEventListener('online', () => {
setTimeout(() => {
window.location.reload();
}, 1000);
});
</script>
</body>
</html>

View File

@ -1,11 +1,19 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
Box,
Typography,
CircularProgress,
Alert,
Paper,
IconButton,
useTheme,
useMediaQuery,
} from '@mui/material';
import {
ZoomIn as ZoomInIcon,
ZoomOut as ZoomOutIcon,
RestartAlt as ResetIcon,
} from '@mui/icons-material';
import { documentService } from '../services/api';
interface DocumentViewerProps {
@ -55,29 +63,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
// Handle images
if (mimeType.startsWith('image/')) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '60vh',
p: 2,
}}
>
<img
src={documentUrl}
alt={filename}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}}
/>
</Box>
);
return <ImageViewer documentUrl={documentUrl} filename={filename} />;
}
// Handle PDFs
@ -152,6 +138,200 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
);
};
// Component for viewing images with touch gestures
const ImageViewer: React.FC<{ documentUrl: string; filename: string }> = ({
documentUrl,
filename,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const imageContainerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const lastTouchDistanceRef = useRef<number | null>(null);
const lastTapTimeRef = useRef<number>(0);
// Reset zoom and position
const handleReset = () => {
setScale(1);
setPosition({ x: 0, y: 0 });
};
// Zoom in
const handleZoomIn = () => {
setScale((prev) => Math.min(prev + 0.5, 5));
};
// Zoom out
const handleZoomOut = () => {
setScale((prev) => Math.max(prev - 0.5, 0.5));
};
// Handle double tap to zoom
const handleDoubleClick = (e: React.MouseEvent) => {
const now = Date.now();
const timeSinceLastTap = now - lastTapTimeRef.current;
if (timeSinceLastTap < 300) {
// Double tap detected
if (scale === 1) {
setScale(2);
} else {
handleReset();
}
}
lastTapTimeRef.current = now;
};
// Handle pinch-to-zoom
const handleTouchStart = (e: React.TouchEvent) => {
if (e.touches.length === 2) {
const distance = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
lastTouchDistanceRef.current = distance;
}
};
const handleTouchMove = (e: React.TouchEvent) => {
if (e.touches.length === 2 && lastTouchDistanceRef.current) {
e.preventDefault();
const distance = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
const scaleDelta = distance / lastTouchDistanceRef.current;
setScale((prev) => Math.max(0.5, Math.min(5, prev * scaleDelta)));
lastTouchDistanceRef.current = distance;
}
};
const handleTouchEnd = () => {
lastTouchDistanceRef.current = null;
};
// Handle wheel zoom
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setScale((prev) => Math.max(0.5, Math.min(5, prev + delta)));
};
return (
<Box sx={{ position: 'relative', width: '100%', minHeight: '60vh' }}>
{/* Zoom Controls */}
{isMobile && (
<Box
sx={{
position: 'absolute',
top: 16,
right: 16,
zIndex: 10,
display: 'flex',
gap: 1,
background: 'rgba(0, 0, 0, 0.6)',
borderRadius: 2,
padding: '4px',
backdropFilter: 'blur(10px)',
}}
>
<IconButton
size="small"
onClick={handleZoomOut}
disabled={scale <= 0.5}
sx={{
color: 'white',
'&:disabled': { color: 'rgba(255,255,255,0.3)' },
}}
>
<ZoomOutIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={handleReset}
sx={{ color: 'white' }}
>
<ResetIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={handleZoomIn}
disabled={scale >= 5}
sx={{
color: 'white',
'&:disabled': { color: 'rgba(255,255,255,0.3)' },
}}
>
<ZoomInIcon fontSize="small" />
</IconButton>
</Box>
)}
{/* Image Container */}
<Box
ref={imageContainerRef}
onClick={handleDoubleClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onWheel={handleWheel}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '60vh',
p: 2,
overflow: 'auto',
cursor: scale > 1 ? 'move' : 'zoom-in',
touchAction: 'none',
userSelect: 'none',
WebkitUserSelect: 'none',
}}
>
<img
ref={imageRef}
src={documentUrl}
alt={filename}
draggable={false}
style={{
maxWidth: scale === 1 ? '100%' : 'none',
maxHeight: scale === 1 ? '100%' : 'none',
objectFit: 'contain',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`,
transition: scale === 1 ? 'transform 0.3s ease-out' : 'none',
transformOrigin: 'center center',
}}
/>
</Box>
{/* Zoom Indicator */}
{isMobile && scale !== 1 && (
<Box
sx={{
position: 'absolute',
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(0, 0, 0, 0.6)',
color: 'white',
padding: '6px 16px',
borderRadius: 2,
fontSize: '0.875rem',
fontWeight: 500,
backdropFilter: 'blur(10px)',
}}
>
{Math.round(scale * 100)}%
</Box>
)}
</Box>
);
};
// Component for viewing text files
const TextFileViewer: React.FC<{ documentUrl: string; filename: string }> = ({
documentUrl,

View File

@ -367,12 +367,8 @@ 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
},
maxWidth: 1200,
minWidth: { xs: 0, sm: 400, md: 600 },
maxWidth: { xs: '100%', sm: 600, md: 800, lg: 1200 },
'& .MuiOutlinedInput-root': {
background: theme.palette.mode === 'light'
? 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.90) 100%)'

View File

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

View File

@ -47,7 +47,6 @@ 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 +81,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);
@ -424,9 +422,15 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
boxShadow: theme.palette.mode === 'light'
? '0 4px 32px rgba(0,0,0,0.04)'
: '0 4px 32px rgba(0,0,0,0.2)',
// iOS safe area support
paddingTop: 'env(safe-area-inset-top, 0px)',
}}
>
<Toolbar>
<Toolbar sx={{
minHeight: { xs: '56px', sm: '64px' },
paddingLeft: { xs: '8px', sm: '16px' },
paddingRight: { xs: '8px', sm: '16px' },
}}>
<IconButton
color="inherit"
aria-label="open drawer"
@ -441,7 +445,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%)',
@ -455,15 +458,14 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
: t('navigation.dashboard')}
</Typography>
{/* Global Search Bar */}
{/* Global Search Bar - Hidden on mobile, use search page instead */}
<Box sx={{
flexGrow: 2,
display: 'flex',
display: { xs: 'none', md: 'flex' },
justifyContent: 'center',
mx: { xs: 0.5, md: 1 },
mx: 1,
flex: '1 1 auto',
minWidth: { xs: 0, md: 'auto' },
overflow: 'hidden',
minWidth: 0 // Allow flex item to shrink below content size
}}>
<GlobalSearchBar />
</Box>
@ -472,8 +474,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
<IconButton
onClick={handleNotificationClick}
sx={{
mr: { xs: 1, md: 2 },
display: isPWA ? 'none' : 'flex',
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%)'
@ -510,8 +511,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 +532,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%)',
@ -671,26 +671,38 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
width: { md: `calc(100% - ${drawerWidth}px)` },
minHeight: '100vh',
backgroundColor: 'background.default',
// Add padding for bottom navigation on mobile
paddingBottom: {
xs: 'calc(64px + env(safe-area-inset-bottom, 0px))',
md: 0,
},
}}
>
<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,
p: { xs: 2, sm: 3 },
// iOS safe area support for notched devices
paddingLeft: {
xs: 'max(16px, env(safe-area-inset-left, 0px))',
sm: 'max(24px, env(safe-area-inset-left, 0px))',
},
paddingRight: {
xs: 'max(16px, env(safe-area-inset-right, 0px))',
sm: 'max(24px, env(safe-area-inset-right, 0px))',
},
}}>
{children}
</Box>
</Box>
{/* Bottom Navigation (Mobile Only) */}
<BottomNavigation />
{/* Notification Panel */}
<NotificationPanel
anchorEl={notificationAnchorEl}
onClose={handleNotificationClose}
/>
{/* Bottom Navigation (PWA only) */}
<BottomNavigation />
</Box>
);
};

View File

@ -8,30 +8,23 @@ import {
import {
Dashboard as DashboardIcon,
CloudUpload as UploadIcon,
Label as LabelIcon,
Search as SearchIcon,
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 === '/search' || pathname === '/documents') return 'search';
if (pathname === '/settings' || pathname === '/profile') return 'settings';
return 'dashboard';
};
@ -44,8 +37,8 @@ const BottomNavigation: React.FC = () => {
case 'upload':
navigate('/upload');
break;
case 'labels':
navigate('/labels');
case 'search':
navigate('/documents');
break;
case 'settings':
navigate('/settings');
@ -72,8 +65,8 @@ const BottomNavigation: React.FC = () => {
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))',
// iOS safe area support
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
}}
elevation={0}
>
@ -153,9 +146,9 @@ const BottomNavigation: React.FC = () => {
}}
/>
<BottomNavigationAction
label={t('navigation.labels')}
value="labels"
icon={<LabelIcon />}
label={t('navigation.documents')}
value="search"
icon={<SearchIcon />}
sx={{
'&.Mui-selected': {
'& .MuiBottomNavigationAction-label': {

View File

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

View File

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

View File

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

View File

@ -2,6 +2,97 @@
@tailwind components;
@tailwind utilities;
/* ============================================
PWA & iOS Safe Area Support
============================================ */
/* Ensure the entire app respects iOS safe areas */
:root {
/* Define safe area insets for iOS devices */
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
}
/* PWA-specific styles */
@media all and (display-mode: standalone) {
/* Remove system tap highlight on iOS PWA */
* {
-webkit-tap-highlight-color: transparent;
}
/* Prevent pull-to-refresh on iOS */
body {
overscroll-behavior-y: contain;
}
/* Improve iOS PWA scrolling performance */
html {
-webkit-overflow-scrolling: touch;
}
}
/* iOS-specific optimizations */
@supports (-webkit-touch-callout: none) {
/* Disable callout on iOS when long-pressing */
* {
-webkit-touch-callout: none;
}
/* Allow callout on text content */
p, span, div[contenteditable="true"] {
-webkit-touch-callout: default;
}
/* Smooth momentum scrolling on iOS */
.scrollable-content {
-webkit-overflow-scrolling: touch;
}
/* Fix iOS input zoom issue */
input, textarea, select {
font-size: 16px !important;
}
}
/* Touch-optimized tap targets (minimum 44x44px for iOS) */
@media (pointer: coarse) {
button,
a,
.MuiIconButton-root,
.MuiButton-root,
.MuiChip-root.MuiChip-clickable {
min-height: 44px;
min-width: 44px;
}
/* Bottom navigation touch targets */
.MuiBottomNavigationAction-root {
min-height: 56px;
}
}
/* Prevent iOS double-tap zoom on buttons */
button,
input[type="button"],
input[type="submit"] {
touch-action: manipulation;
}
/* iOS status bar color adaptation */
@media (prefers-color-scheme: dark) {
html {
background-color: #1e1e1e;
}
}
@media (prefers-color-scheme: light) {
html {
background-color: #ffffff;
}
}
/* Enhanced search responsiveness styles */
.search-input-responsive {
transition: all 0.2s ease-in-out;

View File

@ -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,7 +108,6 @@ 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>('');
@ -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];

View File

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

View File

@ -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(),
};
});
};

View File

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

View File

@ -1,5 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
// Support environment variables for development
const BACKEND_PORT = process.env.BACKEND_PORT || '8000'
@ -8,7 +9,113 @@ const CLIENT_PORT = process.env.CLIENT_PORT || '5173'
const PROXY_TARGET = process.env.VITE_API_PROXY_TARGET || `http://localhost:${BACKEND_PORT}`
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'readur-32.png', 'readur-64.png', 'icons/*.png', 'offline.html'],
manifest: {
name: 'Readur - Document Intelligence Platform',
short_name: 'Readur',
description: 'AI-powered document management with OCR and intelligent search',
theme_color: '#6366f1',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
scope: '/',
start_url: '/',
icons: [
{
src: '/readur-32.png',
sizes: '32x32',
type: 'image/png'
},
{
src: '/readur-64.png',
sizes: '64x64',
type: 'image/png'
},
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any'
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any'
},
{
src: '/icons/icon-192-maskable.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable'
},
{
src: '/icons/icon-512-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
globIgnores: ['**/readur.png'],
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MB limit
// Exclude auth routes from navigation fallback and caching
navigateFallbackDenylist: [/^\/api\/auth\//],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'gstatic-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
// Only cache non-auth API routes
urlPattern: /^\/api\/(?!auth\/).*/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 5 // 5 minutes
},
networkTimeoutSeconds: 10
}
}
]
},
devOptions: {
enabled: false // Disable PWA in dev mode for faster development
}
})
],
test: {
environment: 'jsdom',
setupFiles: ['src/test/setup.ts'],