Handling Multiple Image Uploads per Review in Spring Boot
In this blog post, we'll walk through the process of updating a Spring Boot application to handle multiple image uploads for a single review. This feature allows users to upload multiple images when submitting a review, and ensures that these images are properly stored and displayed.
Initially, our application only supported uploading a single image per review. However, we wanted to enhance the user experience by allowing multiple images to be uploaded for each review. This required changes to both the backend and frontend of our application.
To achieve this, we needed to update our backend to handle multiple file uploads and modify our frontend to support selecting and uploading multiple images.
We updated the ReviewController to accept multiple files and save each file as a separate Media entity associated with the review.
ReviewController.java
@PostMapping
public ResponseEntity<Reviews> createReview(
@RequestHeader("Authorization") String token,
@RequestParam("title") String title,
@RequestParam("content") String content,
@RequestParam("rating") Double rating,
@RequestParam("modelId") Integer modelId,
@RequestParam("publishedAt") String publishedAt,
@RequestParam(value = "photos", required = false) MultipartFile[] photos) throws IOException {
String jwtToken = token.substring(7);
String username = jwtTokenUtil.extractUsername(jwtToken);
Integer userId = reviewService.getUserIdByUsername(username);
Reviews review = new Reviews();
review.setUserId(userId);
review.setTitle(title);
review.setContent(content);
review.setRating(rating);
review.setModelId(modelId);
review.setPublishedAt(new Date());
Reviews createdReview = reviewService.createReview(review);
if (photos != null && photos.length > 0) {
for (MultipartFile photo : photos) {
if (!photo.isEmpty()) {
String fileName = photo.getOriginalFilename();
Path filePath = Paths.get(UPLOAD_DIR, fileName);
try {
Files.createDirectories(filePath.getParent());
Files.write(filePath, photo.getBytes());
Media media = new Media();
media.setUserId(userId);
media.setPostId(createdReview.getPostId());
media.setFileName(fileName);
media.setFileType(photo.getContentType());
media.setFilePath(filePath.toString());
mediaService.saveMedia(media);
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException("Failed to store file " + fileName, e);
}
}
}
}
return ResponseEntity.ok(createdReview);
}
Let me explain how this endpoint handles creating a review with multiple image uploads in our BMW review system.
@PostMapping
public ResponseEntity<Reviews> createReview(
@RequestHeader("Authorization") String token,
@RequestParam("title") String title,
@RequestParam("content") String content,
@RequestParam("rating") Double rating,
@RequestParam("modelId") Integer modelId,
@RequestParam("publishedAt") String publishedAt,
@RequestParam(value = "photos", required = false) MultipartFile[] photos)
Here MultipartFile[] photos
, accepts an array of files, allowing multiple image uploads. The required = false
means users can create reviews without images.
String jwtToken = token.substring(7); // Remove "Bearer " prefix
String username = jwtTokenUtil.extractUsername(jwtToken);
Integer userId = reviewService.getUserIdByUsername(username);
This extracts the user's identity from the JWT token to associate the review with the correct user.
Reviews review = new Reviews();
review.setUserId(userId);
review.setTitle(title);
review.setContent(content);
review.setRating(rating);
review.setModelId(modelId);
review.setPublishedAt(new Date());
Reviews createdReview = reviewService.createReview(review);
First, it creates and saves the review itself, before handling any images.
package findmybmw.backend.model;
import jakarta.persistence.*;
import lombok.Data;
import java.util.Date;
@Entity
@Table(name = "reviews")
@Data
public class Reviews {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id")
private Integer postId;
@Column(name = "user_id", nullable = false)
private Integer userId;
@Column(name = "model_id", nullable = false)
private Integer modelId;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", columnDefinition = "TEXT")
private String content;
@Column(name = "rating")
private Double rating;
@Column(name = "status")
private String status;
@Column(name = "created_at")
@Temporal(TemporalType.TIMESTAMP)
private Date createdAt;
@Column(name = "updated_at")
@Temporal(TemporalType.TIMESTAMP)
private Date updatedAt;
@Column(name = "published_at")
@Temporal(TemporalType.TIMESTAMP)
private Date publishedAt;
@PrePersist
protected void onCreate() {
createdAt = new Date();
updatedAt = new Date();
}
@PreUpdate
protected void onUpdate() {
updatedAt = new Date();
}
}
if (photos != null && photos.length > 0) {
for (MultipartFile photo : photos) {
if (!photo.isEmpty()) {
// Process each photo
}
}
}
This section shows how it handles multiple images:
String fileName = photo.getOriginalFilename();
Path filePath = Paths.get(UPLOAD_DIR, fileName);
try {
Files.createDirectories(filePath.getParent());
Files.write(filePath, photo.getBytes());
For each photo:
Media media = new Media();
media.setUserId(userId);
media.setPostId(createdReview.getPostId());
media.setFileName(fileName);
media.setFileType(photo.getContentType());
media.setFilePath(filePath.toString());
mediaService.saveMedia(media);
For each uploaded image, it creates a Media record in the DB containing:
This creates a link between the review and its associated images.
Let's say a user submits a review with three images:
INSERT INTO reviews (user_id, title, content, rating, model_id)
VALUES (123, 'My BMW Review', '...', 4.5, 456);
/uploads/bmw-front.jpg
/uploads/bmw-side.jpg
/uploads/bmw-interior.jpg
INSERT INTO media (user_id, post_id, file_name, file_type, file_path)
VALUES
(123, 789, 'bmw-front.jpg', 'image/jpeg', './uploads/bmw-front.jpg'),
(123, 789, 'bmw-side.jpg', 'image/jpeg', './uploads/bmw-side.jpg'),
(123, 789, 'bmw-interior.jpg', 'image/jpeg', './uploads/bmw-interior.jpg');
This design allows:
Ensure the Media model is correctly defined to store information about each uploaded file.
Media.java
package findmybmw.backend.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Entity
@Table(name = "media")
@Data
public class Media {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "media_id")
private Integer mediaId;
@Column(name = "user_id", nullable = false)
private Integer userId;
@Column(name = "review_id", nullable = false)
private Integer postId;
@Column(name = "file_name")
private String fileName;
@Column(name = "file_type")
private String fileType;
@Column(name = "file_path")
private String filePath;
@Column(name = "uploaded_at")
@Temporal(TemporalType.TIMESTAMP)
private Date uploadedAt;
@PrePersist
protected void onCreate() {
uploadedAt = new Date();
}
}
We updated the WriteReview component to handle multiple file uploads.
WriteReview.js
import React, { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Navigation from '../navigation/Navigation';
import '../../static/writeReview.scss';
import StarIcon from '@mui/icons-material/Star';
import api from '../../API/api';
function WriteReview() {
const { modelId } = useParams();
const navigate = useNavigate();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [rating, setRating] = useState(0);
const [photos, setPhotos] = useState([]);
const handleSubmit = async (event) => {
event.preventDefault();
const formData = new FormData();
formData.append('title', title);
formData.append('content', content);
formData.append('rating', rating);
formData.append('modelId', modelId);
formData.append('publishedAt', new Date().toISOString());
for (let i = 0; i < photos.length; i++) {
formData.append('photos', photos[i]);
}
try {
const token = localStorage.getItem('token'); // Get the token from localStorage
console.log("token: " + token)
const response = await api.post('/reviews', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Authorization': `Bearer ${token}` // Include the token in the request headers
},
});
console.log('Review submitted successfully', response.data);
navigate(`/review/${modelId}`);
} catch (error) {
console.error('Error submitting review', error.response?.data);
}
};
const handleFileChange = (event) => {
setPhotos([...photos, ...event.target.files]);
};
const renderStars = (rating) => {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(
<StarIcon
key={i}
style={{ color: i <= rating ? '#ffd055' : '#ddd', cursor: 'pointer' }}
onClick={() => setRating(i)}
/>
);
}
return stars;
};
return (
<div>
<Navigation />
<div className="write-review-page">
<h1 className="page-title">Write a Review</h1>
<form className="review-form" onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="title">Title</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="content">Review</label>
<textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
required
></textarea>
</div>
<div className="form-group">
<label>Rating</label>
<div className="star-rating">{renderStars(rating)}</div>
</div>
<div className="form-group">
<label htmlFor="photos">Photos</label>
<input
type="file"
id="photos"
multiple
onChange={handleFileChange}
/>
{photos.length > 0 && (
<div className="file-list">
<h4>Selected Files:</h4>
<ul>
{Array.from(photos).map((file, index) => (
<li key={index}>{file.name}</li>
))}
</ul>
</div>
)}
</div>
<button type="submit" className="submit-button">Submit Review</button>
</form>
</div>
</div>
);
}
export default WriteReview;
const [photos, setPhotos] = useState([]);
Instead of storing a single file, we now use an array to store multiple files. This is crucial for maintaining the list of selected images.
const handleFileChange = (event) => {
setPhotos([...photos, ...event.target.files]);
};
This handler does two important things:
...
) to maintain existing photosconst handleSubmit = async (event) => {
event.preventDefault();
const formData = new FormData();
// Add basic review data
formData.append('title', title);
formData.append('content', content);
formData.append('rating', rating);
formData.append('modelId', modelId);
formData.append('publishedAt', new Date().toISOString());
// Add multiple photos
for (let i = 0; i < photos.length; i++) {
formData.append('photos', photos[i]);
}
// ...
}
The key change here is how we append photos:
<input
type="file"
id="photos"
multiple // Enables multiple file selection
onChange={handleFileChange}
/>
Important attributes:
multiple
allows users to select multiple files{photos.length > 0 && (
<div className="file-list">
<h4>Selected Files:</h4>
<ul>
{Array.from(photos).map((file, index) => (
<li key={index}>{file.name}</li>
))}
</ul>
</div>
)}
This shows users which files they've selected:
This implementation allows users to:
1. Select multiple files at once
2. Add more files in multiple selections
3. See which files they've selected
4. Submit all files together with the review