Spring Boot & React, Handling multiple images per review

박진석·2025년 2월 13일
0

FindMyBMW

목록 보기
10/10

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.

The Problem

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.

The Solution

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.

Step 1: Update the Backend

1.1 Modify the ReviewController

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);
    }
    
    

Breaking Down the Review Creation with Multiple Image Uploads

Let me explain how this endpoint handles creating a review with multiple image uploads in our BMW review system.

1. Endpoint Parameters

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

2. User Authentication

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.

3. Creating the Review

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();
    }
}

4. Handling Multiple Images

if (photos != null && photos.length > 0) {
    for (MultipartFile photo : photos) {
        if (!photo.isEmpty()) {
            // Process each photo
        }
    }
}

This section shows how it handles multiple images:

  • Checks if any photos were uploaded
  • Loops through each photo in the array
  • Processes each non-empty photo individually

5. Processing Each Image

String fileName = photo.getOriginalFilename();
Path filePath = Paths.get(UPLOAD_DIR, fileName);
try {
    Files.createDirectories(filePath.getParent());
    Files.write(filePath, photo.getBytes());

For each photo:

  • Gets the original filename
  • Creates a file path in the upload directory
  • Ensures the directory exists
  • Writes the file to disk

6. Creating Media Records

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:

  • The user who uploaded it
  • The review it belongs to
  • File details (name, type, path)

This creates a link between the review and its associated images.

Example Flow

Let's say a user submits a review with three images:

  1. The review data is saved first:
INSERT INTO reviews (user_id, title, content, rating, model_id) 
VALUES (123, 'My BMW Review', '...', 4.5, 456);
  1. Then for each image, two things happen:
    • File is saved to disk:
      /uploads/bmw-front.jpg
      /uploads/bmw-side.jpg
      /uploads/bmw-interior.jpg
    • Media record is created:
      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:

  • Multiple images per review
  • Easy retrieval of all images for a review
  • Tracking of who uploaded what
  • File type validation
  • Separate storage of files and their metadata

1.2 Update the Media Model

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();
    }
}

Step 2: Update the Frontend

2.1 Modify the WriteReview Component

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;

State Management for Multiple Files

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.

File Input Handler

const handleFileChange = (event) => {
  setPhotos([...photos, ...event.target.files]);
};

This handler does two important things:

  • Uses the spread operator (...) to maintain existing photos
  • Adds new files from the input to the array
  • By spreading both arrays, we preserve previously selected files while adding new ones

Form Data Creation

const 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:

  • Uses a loop to append each photo individually
  • Each file gets the same field name ('photos')
  • This matches the backend expectation of receiving an array

File Input Element

<input
  type="file"
  id="photos"
  multiple           // Enables multiple file selection
  onChange={handleFileChange}
/>

Important attributes:

  • multiple allows users to select multiple files
  • The input directly handles multiple file selection

Preview Selected 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:

  • Only appears when files are selected
  • Lists all selected file names
  • Provides visual feedback to users

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

Results

0개의 댓글