This implements the mobile optimization agent (P3 - Enhancement) with: PWA Configuration: - Add next-pwa integration with offline caching strategies - Create web app manifest for installability - Add service worker with background sync support - Create offline fallback page Mobile Components: - BottomNav: Touch-friendly bottom navigation bar - MobileHeader: Responsive header with back navigation - InstallPrompt: Smart PWA install prompt (iOS & Android) - SwipeableCard: Gesture-based swipeable cards - PullToRefresh: Native-like pull to refresh - QRScanner: Camera-based QR code scanning Mobile Library: - camera.ts: Camera access and photo capture utilities - offline.ts: IndexedDB-based offline storage and sync - gestures.ts: Touch gesture detection (swipe, pinch, tap) - pwa.ts: PWA status, install prompts, service worker management Mobile Pages: - /m: Mobile dashboard with quick actions and stats - /m/scan: QR code scanner for plant lookup - /m/quick-add: Streamlined plant registration form - /m/profile: User profile with offline status Dependencies added: next-pwa, idb
131 lines
3.5 KiB
TypeScript
131 lines
3.5 KiB
TypeScript
import * as React from 'react';
|
|
import classNames from 'classnames';
|
|
|
|
interface SwipeableCardProps {
|
|
children: React.ReactNode;
|
|
onSwipeLeft?: () => void;
|
|
onSwipeRight?: () => void;
|
|
leftAction?: React.ReactNode;
|
|
rightAction?: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
export function SwipeableCard({
|
|
children,
|
|
onSwipeLeft,
|
|
onSwipeRight,
|
|
leftAction,
|
|
rightAction,
|
|
className,
|
|
}: SwipeableCardProps) {
|
|
const cardRef = React.useRef<HTMLDivElement>(null);
|
|
const [startX, setStartX] = React.useState(0);
|
|
const [currentX, setCurrentX] = React.useState(0);
|
|
const [isSwiping, setIsSwiping] = React.useState(false);
|
|
|
|
const threshold = 100;
|
|
const maxSwipe = 150;
|
|
|
|
const handleTouchStart = (e: React.TouchEvent) => {
|
|
setStartX(e.touches[0].clientX);
|
|
setIsSwiping(true);
|
|
};
|
|
|
|
const handleTouchMove = (e: React.TouchEvent) => {
|
|
if (!isSwiping) return;
|
|
|
|
const diff = e.touches[0].clientX - startX;
|
|
const clampedDiff = Math.max(-maxSwipe, Math.min(maxSwipe, diff));
|
|
|
|
// Only allow swiping in directions that have actions
|
|
if (diff > 0 && !onSwipeRight) return;
|
|
if (diff < 0 && !onSwipeLeft) return;
|
|
|
|
setCurrentX(clampedDiff);
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
setIsSwiping(false);
|
|
|
|
if (currentX > threshold && onSwipeRight) {
|
|
onSwipeRight();
|
|
} else if (currentX < -threshold && onSwipeLeft) {
|
|
onSwipeLeft();
|
|
}
|
|
|
|
setCurrentX(0);
|
|
};
|
|
|
|
const swipeProgress = Math.abs(currentX) / threshold;
|
|
const direction = currentX > 0 ? 'right' : 'left';
|
|
|
|
return (
|
|
<div className={classNames('relative overflow-hidden rounded-lg', className)}>
|
|
{/* Left action background */}
|
|
{rightAction && (
|
|
<div
|
|
className={classNames(
|
|
'absolute inset-y-0 left-0 flex items-center justify-start pl-4 bg-green-500 transition-opacity',
|
|
{
|
|
'opacity-100': currentX > 0,
|
|
'opacity-0': currentX <= 0,
|
|
}
|
|
)}
|
|
style={{ width: Math.abs(currentX) }}
|
|
>
|
|
{rightAction}
|
|
</div>
|
|
)}
|
|
|
|
{/* Right action background */}
|
|
{leftAction && (
|
|
<div
|
|
className={classNames(
|
|
'absolute inset-y-0 right-0 flex items-center justify-end pr-4 bg-red-500 transition-opacity',
|
|
{
|
|
'opacity-100': currentX < 0,
|
|
'opacity-0': currentX >= 0,
|
|
}
|
|
)}
|
|
style={{ width: Math.abs(currentX) }}
|
|
>
|
|
{leftAction}
|
|
</div>
|
|
)}
|
|
|
|
{/* Main card content */}
|
|
<div
|
|
ref={cardRef}
|
|
className="relative bg-white transition-transform touch-pan-y"
|
|
style={{
|
|
transform: `translateX(${currentX}px)`,
|
|
transitionDuration: isSwiping ? '0ms' : '200ms',
|
|
}}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
{children}
|
|
</div>
|
|
|
|
{/* Swipe indicator */}
|
|
{isSwiping && Math.abs(currentX) > 20 && (
|
|
<div
|
|
className={classNames(
|
|
'absolute top-1/2 transform -translate-y-1/2 text-white text-sm font-medium',
|
|
{
|
|
'left-4': direction === 'right',
|
|
'right-4': direction === 'left',
|
|
}
|
|
)}
|
|
style={{ opacity: swipeProgress }}
|
|
>
|
|
{direction === 'right' && onSwipeRight && 'Release to confirm'}
|
|
{direction === 'left' && onSwipeLeft && 'Release to delete'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default SwipeableCard;
|