diff --git a/frontend/index.html b/frontend/index.html
index 8e4f623..8408550 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,24 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
index 1ebe754..db8399c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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"
}
}
diff --git a/frontend/public/icons/apple-touch-icon.png b/frontend/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000..2f42a4e
Binary files /dev/null and b/frontend/public/icons/apple-touch-icon.png differ
diff --git a/frontend/public/icons/icon-192-maskable.png b/frontend/public/icons/icon-192-maskable.png
new file mode 100644
index 0000000..466d378
Binary files /dev/null and b/frontend/public/icons/icon-192-maskable.png differ
diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png
new file mode 100644
index 0000000..c4007f4
Binary files /dev/null and b/frontend/public/icons/icon-192.png differ
diff --git a/frontend/public/icons/icon-512-maskable.png b/frontend/public/icons/icon-512-maskable.png
new file mode 100644
index 0000000..b676002
Binary files /dev/null and b/frontend/public/icons/icon-512-maskable.png differ
diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png
new file mode 100644
index 0000000..2a41f4c
Binary files /dev/null and b/frontend/public/icons/icon-512.png differ
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
new file mode 100644
index 0000000..41baa66
--- /dev/null
+++ b/frontend/public/manifest.json
@@ -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
+}
diff --git a/frontend/public/offline.html b/frontend/public/offline.html
new file mode 100644
index 0000000..36cbb76
--- /dev/null
+++ b/frontend/public/offline.html
@@ -0,0 +1,168 @@
+
+
+
+
+
+ Offline - Readur
+
+
+
+
+
+
+
You're Offline
+
It looks like you've lost your internet connection. Don't worry, Readur will be back once you're online again.
+
+
+
Checking connection...
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/DocumentViewer.tsx b/frontend/src/components/DocumentViewer.tsx
index 9081c72..2645be8 100644
--- a/frontend/src/components/DocumentViewer.tsx
+++ b/frontend/src/components/DocumentViewer.tsx
@@ -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 = ({
// Handle images
if (mimeType.startsWith('image/')) {
- return (
-
-
-
- );
+ return ;
}
// Handle PDFs
@@ -152,6 +138,200 @@ const DocumentViewer: React.FC = ({
);
};
+// 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(null);
+ const imageRef = useRef(null);
+ const lastTouchDistanceRef = useRef(null);
+ const lastTapTimeRef = useRef(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 (
+
+ {/* Zoom Controls */}
+ {isMobile && (
+
+
+
+
+
+
+
+ = 5}
+ sx={{
+ color: 'white',
+ '&:disabled': { color: 'rgba(255,255,255,0.3)' },
+ }}
+ >
+
+
+
+ )}
+
+ {/* Image Container */}
+ 1 ? 'move' : 'zoom-in',
+ touchAction: 'none',
+ userSelect: 'none',
+ WebkitUserSelect: 'none',
+ }}
+ >
+
+
+
+ {/* Zoom Indicator */}
+ {isMobile && scale !== 1 && (
+
+ {Math.round(scale * 100)}%
+
+ )}
+
+ );
+};
+
// Component for viewing text files
const TextFileViewer: React.FC<{ documentUrl: string; filename: string }> = ({
documentUrl,
diff --git a/frontend/src/components/GlobalSearchBar/GlobalSearchBar.tsx b/frontend/src/components/GlobalSearchBar/GlobalSearchBar.tsx
index 509d2a8..5bf20fe 100644
--- a/frontend/src/components/GlobalSearchBar/GlobalSearchBar.tsx
+++ b/frontend/src/components/GlobalSearchBar/GlobalSearchBar.tsx
@@ -367,8 +367,8 @@ const GlobalSearchBar: React.FC = ({ sx, ...props }) => {
}}
sx={{
width: '100%',
- minWidth: 600,
- 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%)'
diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx
index 03a2a55..0d70762 100644
--- a/frontend/src/components/Layout/AppLayout.tsx
+++ b/frontend/src/components/Layout/AppLayout.tsx
@@ -46,6 +46,7 @@ import GlobalSearchBar from '../GlobalSearchBar';
import ThemeToggle from '../ThemeToggle/ThemeToggle';
import NotificationPanel from '../Notifications/NotificationPanel';
import LanguageSwitcher from '../LanguageSwitcher';
+import BottomNavigation from './BottomNavigation';
import { useTranslation } from 'react-i18next';
const drawerWidth = 280;
@@ -421,9 +422,15 @@ const AppLayout: React.FC = ({ 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)',
}}
>
-
+
= ({ children }) => {
: t('navigation.dashboard')}
- {/* Global Search Bar */}
-
+ {/* Global Search Bar - Hidden on mobile, use search page instead */}
+
@@ -657,18 +671,37 @@ const AppLayout: React.FC = ({ 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,
+ },
}}
>
-
+
{children}
+ {/* Bottom Navigation (Mobile Only) */}
+
+
{/* Notification Panel */}
-
);
diff --git a/frontend/src/components/Layout/BottomNavigation.tsx b/frontend/src/components/Layout/BottomNavigation.tsx
new file mode 100644
index 0000000..780fb35
--- /dev/null
+++ b/frontend/src/components/Layout/BottomNavigation.tsx
@@ -0,0 +1,185 @@
+import React from 'react';
+import {
+ BottomNavigation as MuiBottomNavigation,
+ BottomNavigationAction,
+ Paper,
+ useTheme,
+} from '@mui/material';
+import {
+ Dashboard as DashboardIcon,
+ CloudUpload as UploadIcon,
+ Search as SearchIcon,
+ Settings as SettingsIcon,
+} from '@mui/icons-material';
+import { useNavigate, useLocation } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+
+const BottomNavigation: React.FC = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const theme = useTheme();
+ const { t } = useTranslation();
+
+ // Map paths to nav values
+ const getNavValue = (pathname: string): string => {
+ if (pathname === '/dashboard') return 'dashboard';
+ if (pathname === '/upload') return 'upload';
+ if (pathname === '/search' || pathname === '/documents') return 'search';
+ 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 'search':
+ navigate('/documents');
+ break;
+ case 'settings':
+ navigate('/settings');
+ break;
+ }
+ };
+
+ return (
+
+
+ }
+ sx={{
+ '&.Mui-selected': {
+ '& .MuiBottomNavigationAction-label': {
+ background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
+ backgroundClip: 'text',
+ WebkitBackgroundClip: 'text',
+ WebkitTextFillColor: 'transparent',
+ fontWeight: 600,
+ },
+ },
+ }}
+ />
+ }
+ sx={{
+ '&.Mui-selected': {
+ '& .MuiBottomNavigationAction-label': {
+ background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
+ backgroundClip: 'text',
+ WebkitBackgroundClip: 'text',
+ WebkitTextFillColor: 'transparent',
+ fontWeight: 600,
+ },
+ },
+ }}
+ />
+ }
+ sx={{
+ '&.Mui-selected': {
+ '& .MuiBottomNavigationAction-label': {
+ background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
+ backgroundClip: 'text',
+ WebkitBackgroundClip: 'text',
+ WebkitTextFillColor: 'transparent',
+ fontWeight: 600,
+ },
+ },
+ }}
+ />
+ }
+ sx={{
+ '&.Mui-selected': {
+ '& .MuiBottomNavigationAction-label': {
+ background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
+ backgroundClip: 'text',
+ WebkitBackgroundClip: 'text',
+ WebkitTextFillColor: 'transparent',
+ fontWeight: 600,
+ },
+ },
+ }}
+ />
+
+
+ );
+};
+
+export default BottomNavigation;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 5e001a1..c42fd0e 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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;
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 7b89620..861e63d 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -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'],