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>
This commit is contained in:
@@ -153,6 +153,8 @@ export function ChildDialog({ open, onClose, onSubmit, child, isLoading = false
|
||||
onChange={(url) => setFormData({ ...formData, photoUrl: url })}
|
||||
disabled={isLoading}
|
||||
size={80}
|
||||
childId={child?.id}
|
||||
type="profile"
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
Typography,
|
||||
Paper,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { PhotoCamera, Person } from '@mui/icons-material';
|
||||
import { photosApi } from '@/lib/api/photos';
|
||||
|
||||
interface PhotoUploadProps {
|
||||
value: string;
|
||||
@@ -18,6 +20,8 @@ interface PhotoUploadProps {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
size?: number;
|
||||
childId?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export function PhotoUpload({
|
||||
@@ -25,10 +29,13 @@ export function PhotoUpload({
|
||||
onChange,
|
||||
label,
|
||||
disabled = false,
|
||||
size = 100
|
||||
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 = () => {
|
||||
@@ -49,29 +56,36 @@ export function PhotoUpload({
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
// Validate file size (max 10MB - backend limit)
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > maxSize) {
|
||||
setUploadError('Image size must be less than 5MB');
|
||||
setUploadError('Image size must be less than 10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const base64String = e.target?.result as string;
|
||||
onChange(base64String);
|
||||
try {
|
||||
setUploading(true);
|
||||
setUploadError('');
|
||||
setImageError(false);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setUploadError('Failed to read image file');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
// 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 = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,7 +140,7 @@ export function PhotoUpload({
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
disabled={disabled}
|
||||
disabled={disabled || uploading}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
@@ -142,10 +156,14 @@ export function PhotoUpload({
|
||||
},
|
||||
}}
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
disabled={disabled || uploading}
|
||||
onClick={handleCameraClick}
|
||||
>
|
||||
<PhotoCamera fontSize="small" />
|
||||
{uploading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<PhotoCamera fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -156,8 +174,12 @@ export function PhotoUpload({
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
disabled={disabled}
|
||||
helperText="Click camera to upload or paste an image URL"
|
||||
disabled={disabled || uploading}
|
||||
helperText={
|
||||
uploading
|
||||
? 'Uploading and optimizing image...'
|
||||
: 'Click camera to upload (auto-optimized) or paste URL'
|
||||
}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
48
maternal-web/lib/api/photos.ts
Normal file
48
maternal-web/lib/api/photos.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface PhotoUploadResult {
|
||||
photo: {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
originalUrl?: string;
|
||||
storageKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const photosApi = {
|
||||
/**
|
||||
* Upload a photo for profile/child
|
||||
* Uses multipart/form-data to send file to backend
|
||||
* Backend handles image optimization via Sharp and storage via MinIO
|
||||
*/
|
||||
uploadPhoto: async (
|
||||
file: File,
|
||||
options?: {
|
||||
childId?: string;
|
||||
type?: string;
|
||||
caption?: string;
|
||||
}
|
||||
): Promise<PhotoUploadResult> => {
|
||||
const formData = new FormData();
|
||||
formData.append('photo', file);
|
||||
|
||||
if (options?.childId) {
|
||||
formData.append('childId', options.childId);
|
||||
}
|
||||
if (options?.type) {
|
||||
formData.append('type', options.type);
|
||||
}
|
||||
if (options?.caption) {
|
||||
formData.append('caption', options.caption);
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/api/v1/photos/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user