feat: Integrate photo upload with MinIO storage and Sharp optimization
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

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:
2025-10-04 08:23:15 +00:00
parent 07d5d3e55c
commit 0e13401148
4 changed files with 96 additions and 24 deletions

View File

@@ -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>

View File

@@ -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,30 +56,37 @@ 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('');
// 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);
};
reader.onerror = () => {
setUploadError('Failed to read image file');
};
reader.readAsDataURL(file);
} 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 = () => {
@@ -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}
>
{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>

View 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