Files
maternal-app/maternal-web/components/common/PhotoUpload.tsx
Andrei 0e13401148
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
feat: Integrate photo upload with MinIO storage and Sharp optimization
Now uses the existing infrastructure instead of base64:
- Created photos API client for multipart/form-data upload
- Upload to /api/v1/photos/upload endpoint
- Backend handles Sharp image optimization (resize, compress, format conversion)
- MinIO/S3-compatible storage for scalable file management
- 10MB file size limit (up from 5MB base64)
- Shows upload progress with spinner
- Returns optimized CDN-ready URLs
- Proper error handling with backend validation

Benefits over previous base64 approach:
 Images optimized with Sharp (smaller sizes, better quality)
 Stored in MinIO (scalable object storage)
 CDN-ready URLs for fast delivery
 No database bloat from base64 strings
 Supports larger files (10MB vs 5MB)
 Automatic thumbnail generation
 Better performance and scalability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 08:23:15 +00:00

188 lines
4.6 KiB
TypeScript

'use client';
import { useState, useRef } from 'react';
import {
Box,
Avatar,
IconButton,
TextField,
Typography,
Paper,
Alert,
CircularProgress,
} from '@mui/material';
import { PhotoCamera, Person } from '@mui/icons-material';
import { photosApi } from '@/lib/api/photos';
interface PhotoUploadProps {
value: string;
onChange: (url: string) => void;
label: string;
disabled?: boolean;
size?: number;
childId?: string;
type?: string;
}
export function PhotoUpload({
value,
onChange,
label,
disabled = false,
size = 100,
childId,
type = 'profile'
}: PhotoUploadProps) {
const [imageError, setImageError] = useState(false);
const [uploadError, setUploadError] = useState<string>('');
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImageError = () => {
setImageError(true);
};
const handleImageLoad = () => {
setImageError(false);
};
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
setUploadError('Please select an image file');
return;
}
// Validate file size (max 10MB - backend limit)
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
setUploadError('Image size must be less than 10MB');
return;
}
try {
setUploading(true);
setUploadError('');
// Upload to backend - it handles Sharp optimization and MinIO storage
const result = await photosApi.uploadPhoto(file, {
childId,
type,
});
// Use the optimized URL from backend
onChange(result.photo.url);
setImageError(false);
} catch (error: any) {
console.error('Photo upload failed:', error);
setUploadError(error.response?.data?.message || 'Failed to upload photo');
} finally {
setUploading(false);
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleCameraClick = () => {
fileInputRef.current?.click();
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, alignItems: 'center' }}>
<Paper
elevation={0}
sx={{
p: 2,
border: 1,
borderColor: 'divider',
borderRadius: 2,
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2
}}
>
<Typography variant="body2" color="text.secondary">
{label}
</Typography>
{uploadError && (
<Alert severity="error" onClose={() => setUploadError('')} sx={{ width: '100%' }}>
{uploadError}
</Alert>
)}
<Box sx={{ position: 'relative' }}>
<Avatar
src={!imageError && value ? value : undefined}
sx={{
width: size,
height: size,
bgcolor: 'primary.light',
fontSize: size / 3,
}}
onError={handleImageError}
onLoad={handleImageLoad}
>
{!value || imageError ? <Person sx={{ fontSize: size / 2 }} /> : null}
</Avatar>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileSelect}
disabled={disabled || uploading}
/>
<IconButton
sx={{
position: 'absolute',
bottom: -4,
right: -4,
bgcolor: 'background.paper',
border: 2,
borderColor: 'divider',
'&:hover': {
bgcolor: 'action.hover',
},
}}
size="small"
disabled={disabled || uploading}
onClick={handleCameraClick}
>
{uploading ? (
<CircularProgress size={20} />
) : (
<PhotoCamera fontSize="small" />
)}
</IconButton>
</Box>
<TextField
label="Photo URL"
value={value}
onChange={(e) => onChange(e.target.value)}
fullWidth
size="small"
placeholder="https://example.com/photo.jpg"
disabled={disabled || uploading}
helperText={
uploading
? 'Uploading and optimizing image...'
: 'Click camera to upload (auto-optimized) or paste URL'
}
/>
</Paper>
</Box>
);
}