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 })}
|
onChange={(url) => setFormData({ ...formData, photoUrl: url })}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
size={80}
|
size={80}
|
||||||
|
childId={child?.id}
|
||||||
|
type="profile"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Paper,
|
Paper,
|
||||||
Alert,
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { PhotoCamera, Person } from '@mui/icons-material';
|
import { PhotoCamera, Person } from '@mui/icons-material';
|
||||||
|
import { photosApi } from '@/lib/api/photos';
|
||||||
|
|
||||||
interface PhotoUploadProps {
|
interface PhotoUploadProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -18,6 +20,8 @@ interface PhotoUploadProps {
|
|||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
childId?: string;
|
||||||
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhotoUpload({
|
export function PhotoUpload({
|
||||||
@@ -25,10 +29,13 @@ export function PhotoUpload({
|
|||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
size = 100
|
size = 100,
|
||||||
|
childId,
|
||||||
|
type = 'profile'
|
||||||
}: PhotoUploadProps) {
|
}: PhotoUploadProps) {
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const [uploadError, setUploadError] = useState<string>('');
|
const [uploadError, setUploadError] = useState<string>('');
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleImageError = () => {
|
const handleImageError = () => {
|
||||||
@@ -49,29 +56,36 @@ export function PhotoUpload({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file size (max 5MB)
|
// Validate file size (max 10MB - backend limit)
|
||||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||||
if (file.size > maxSize) {
|
if (file.size > maxSize) {
|
||||||
setUploadError('Image size must be less than 5MB');
|
setUploadError('Image size must be less than 10MB');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to base64
|
try {
|
||||||
const reader = new FileReader();
|
setUploading(true);
|
||||||
reader.onload = (e) => {
|
|
||||||
const base64String = e.target?.result as string;
|
|
||||||
onChange(base64String);
|
|
||||||
setUploadError('');
|
setUploadError('');
|
||||||
setImageError(false);
|
|
||||||
};
|
|
||||||
reader.onerror = () => {
|
|
||||||
setUploadError('Failed to read image file');
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
|
|
||||||
// Reset input
|
// Upload to backend - it handles Sharp optimization and MinIO storage
|
||||||
if (fileInputRef.current) {
|
const result = await photosApi.uploadPhoto(file, {
|
||||||
fileInputRef.current.value = '';
|
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/*"
|
accept="image/*"
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
disabled={disabled}
|
disabled={disabled || uploading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -142,10 +156,14 @@ export function PhotoUpload({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={disabled}
|
disabled={disabled || uploading}
|
||||||
onClick={handleCameraClick}
|
onClick={handleCameraClick}
|
||||||
>
|
>
|
||||||
<PhotoCamera fontSize="small" />
|
{uploading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : (
|
||||||
|
<PhotoCamera fontSize="small" />
|
||||||
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -156,8 +174,12 @@ export function PhotoUpload({
|
|||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="https://example.com/photo.jpg"
|
placeholder="https://example.com/photo.jpg"
|
||||||
disabled={disabled}
|
disabled={disabled || uploading}
|
||||||
helperText="Click camera to upload or paste an image URL"
|
helperText={
|
||||||
|
uploading
|
||||||
|
? 'Uploading and optimizing image...'
|
||||||
|
: 'Click camera to upload (auto-optimized) or paste URL'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</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