Merge branch 'feat-pwa-support' into main

This commit is contained in:
Alex
2025-12-11 15:45:28 -08:00
committed by GitHub
15 changed files with 896 additions and 36 deletions

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

@@ -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,8 +367,8 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ 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%)'

View File

@@ -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<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"
@@ -451,8 +458,15 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
: t('navigation.dashboard')}
</Typography>
{/* Global Search Bar */}
<Box sx={{ flexGrow: 2, display: 'flex', justifyContent: 'center', mx: 1, flex: '1 1 auto' }}>
{/* Global Search Bar - Hidden on mobile, use search page instead */}
<Box sx={{
flexGrow: 2,
display: { xs: 'none', md: 'flex' },
justifyContent: 'center',
mx: 1,
flex: '1 1 auto',
minWidth: 0 // Allow flex item to shrink below content size
}}>
<GlobalSearchBar />
</Box>
@@ -657,18 +671,37 @@ 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 }}>
<Box sx={{
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}
<NotificationPanel
anchorEl={notificationAnchorEl}
onClose={handleNotificationClose}
/>
</Box>
);

View File

@@ -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 (
<Paper
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1100,
display: { xs: 'block', md: 'none' },
background: theme.palette.mode === 'light'
? 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(248,250,252,0.98) 100%)'
: 'linear-gradient(180deg, rgba(30,30,30,0.98) 0%, rgba(18,18,18,0.98) 100%)',
backdropFilter: 'blur(20px)',
borderTop: theme.palette.mode === 'light'
? '1px solid rgba(226,232,240,0.5)'
: '1px solid rgba(255,255,255,0.1)',
boxShadow: theme.palette.mode === 'light'
? '0 -4px 32px rgba(0,0,0,0.08)'
: '0 -4px 32px rgba(0,0,0,0.3)',
// iOS safe area support
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
}}
elevation={0}
>
<MuiBottomNavigation
value={getNavValue(location.pathname)}
onChange={handleNavigation}
sx={{
background: 'transparent',
height: '64px',
'& .MuiBottomNavigationAction-root': {
color: 'text.secondary',
minWidth: 'auto',
padding: '8px 12px',
gap: '4px',
transition: 'all 0.2s ease-in-out',
'& .MuiBottomNavigationAction-label': {
fontSize: '0.75rem',
fontWeight: 500,
letterSpacing: '0.025em',
marginTop: '4px',
transition: 'all 0.2s ease-in-out',
'&.Mui-selected': {
fontSize: '0.75rem',
},
},
'& .MuiSvgIcon-root': {
fontSize: '1.5rem',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
},
'&.Mui-selected': {
color: '#6366f1',
'& .MuiSvgIcon-root': {
transform: 'scale(1.1)',
filter: 'drop-shadow(0 2px 8px rgba(99,102,241,0.3))',
},
},
// iOS-style touch feedback
'@media (pointer: coarse)': {
minHeight: '56px',
'&:active': {
transform: 'scale(0.95)',
},
},
},
}}
>
<BottomNavigationAction
label={t('navigation.dashboard')}
value="dashboard"
icon={<DashboardIcon />}
sx={{
'&.Mui-selected': {
'& .MuiBottomNavigationAction-label': {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontWeight: 600,
},
},
}}
/>
<BottomNavigationAction
label={t('navigation.upload')}
value="upload"
icon={<UploadIcon />}
sx={{
'&.Mui-selected': {
'& .MuiBottomNavigationAction-label': {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontWeight: 600,
},
},
}}
/>
<BottomNavigationAction
label={t('navigation.documents')}
value="search"
icon={<SearchIcon />}
sx={{
'&.Mui-selected': {
'& .MuiBottomNavigationAction-label': {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontWeight: 600,
},
},
}}
/>
<BottomNavigationAction
label={t('settings.title')}
value="settings"
icon={<SettingsIcon />}
sx={{
'&.Mui-selected': {
'& .MuiBottomNavigationAction-label': {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontWeight: 600,
},
},
}}
/>
</MuiBottomNavigation>
</Paper>
);
};
export default BottomNavigation;

View File

@@ -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,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'],