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 ( - - {filename} - - ); + 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', + }} + > + {filename} + + + {/* 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'],