We will create a Recipe Organizer with Thymeleaf and Spring Boot in
this tutorial. This is a straightforward Spring MVC Web application. It will
assist us in organizing our favorite recipes.
POM.XML
First and foremost, we require a Spring Boot application. Here is the Spring
Boot application's pom.xml file:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="UTF-8"?> | |
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> | |
<modelVersion>4.0.0</modelVersion> | |
<parent> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-parent</artifactId> | |
<version>2.7.9</version> | |
<relativePath/> <!-- lookup parent from repository --> | |
</parent> | |
<groupId>com.raven</groupId> | |
<artifactId>springboot-thymeleaf-recipe-organizer</artifactId> | |
<version>0.0.1-SNAPSHOT</version> | |
<name>springboot-thymeleaf-recipe-organizer</name> | |
<description>Recipe Organizer with Thymeleaf and Spring Boot</description> | |
<properties> | |
<java.version>11</java.version> | |
</properties> | |
<dependencies> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-data-jpa</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-web</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-thymeleaf</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-devtools</artifactId> | |
<scope>runtime</scope> | |
<optional>true</optional> | |
</dependency> | |
<dependency> | |
<groupId>com.mysql</groupId> | |
<artifactId>mysql-connector-j</artifactId> | |
<scope>runtime</scope> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-test</artifactId> | |
<scope>test</scope> | |
</dependency> | |
</dependencies> | |
<build> | |
<finalName>springboot-thymeleaf-recipe-organizer</finalName> | |
<plugins> | |
<plugin> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-maven-plugin</artifactId> | |
</plugin> | |
</plugins> | |
</build> | |
</project> |
Spring Boot version 2.7.9 was used in this example. The Spring
Framework version for this version of Spring Boot is 5.3.25. This
application contains the spring-boot-starter-data-jpa,
spring-boot-starter-web, spring-boot-starter-thymeleaf, and
mysql-connector-j dependencies.
application.properties
We have configured the data source and file upload settings here.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
server.port=9090 | |
## DATA SOURCE | |
spring.datasource.url=jdbc:mysql://172.17.0.2:3306/recipe_organizer?useSSL=false | |
spring.datasource.username=root | |
spring.datasource.password=admin@123 | |
spring.jpa.hibernate.ddl-auto=update | |
spring.jpa.show-sql=true | |
spring.jpa.properties.hibernate.format_sql=true | |
spring.servlet.multipart.max-file-size=10MB | |
spring.servlet.multipart.max-request-size=10MB |
Entities
For this application, two entities are required: RecipeImage for recipe
images and Recipe for recipe details.
The RecipeImage entity's code snippets are as follows:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.raven.springbootthymeleafrecipeorganizer.entity; | |
// imports are omitted | |
@Entity | |
@Table(name = "RECIPE_IMAGE") | |
public class RecipeImage { | |
@Id | |
@GeneratedValue(strategy = GenerationType.IDENTITY) | |
@Column(name = "id") | |
private int Id; | |
@Column(name = "original_filename", columnDefinition = "varchar(40)") | |
private String originalFileName = ""; | |
@Column(name = "path", columnDefinition = "varchar(120)") | |
private String path = ""; | |
@Temporal(TemporalType.TIMESTAMP) | |
@Column(name = "created_on", nullable = false, updatable = false) | |
@CreatedDate | |
private Date createdOn = new Date(); | |
public RecipeImage() { | |
} | |
public RecipeImage(String originalFileName, String path) { | |
this.originalFileName = originalFileName; | |
this.path = path; | |
} | |
public int getId() { | |
return Id; | |
} | |
public String getOriginalFileName() { | |
return originalFileName; | |
} | |
public String getPath() { | |
return path; | |
} | |
public Date getCreatedOn() { | |
return createdOn; | |
} | |
public void setOriginalFileName(String originalFileName) { | |
this.originalFileName = originalFileName; | |
} | |
public void setPath(String path) { | |
this.path = path; | |
} | |
@Override | |
public String toString() { | |
return "RecipeImage [Id=" + Id + ", originalFileName=" + originalFileName | |
+ ", path=" + path + ", createdOn=" + createdOn + "]"; | |
} | |
} |
Using this entity, we will create a table in the database called
RECIPE_IMAGE to store the image name and path.
The Recipe entity's code snippets are now as follows:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.raven.springbootthymeleafrecipeorganizer.entity; | |
// imports are omitted | |
@Entity | |
@Table(name = "RECIPE_DETAILS") | |
public class Recipe { | |
@Id | |
@GeneratedValue(strategy = GenerationType.IDENTITY) | |
@Column(name = "id") | |
private int Id; | |
@Column(name = "recipe_name", columnDefinition = "varchar(100)", nullable = false) | |
private String recipeName = ""; | |
@Column(name = "description", columnDefinition = "text") | |
private String description = ""; | |
@Column(name = "dish_type", columnDefinition = "varchar(30)") | |
private String dishType = ""; | |
@Column(name = "preparation_time", columnDefinition = "varchar(20)") | |
private String preparationTime = ""; | |
@Column(name = "cooking_time", columnDefinition = "varchar(20)") | |
private String cookingTime = ""; | |
@Column(name = "ingredients", columnDefinition = "text") | |
private String ingredients = ""; | |
@Column(name = "directions", columnDefinition = "longtext") | |
private String directions = ""; | |
@OneToOne(cascade = CascadeType.ALL) | |
@JoinColumn(name = "image_id") | |
private RecipeImage recipeImage; | |
@Transient | |
private String imageName = ""; | |
@Transient | |
private String imageURL = ""; | |
@Temporal(TemporalType.TIMESTAMP) | |
@Column(name = "created_on", nullable = false, updatable = false) | |
@CreatedDate | |
private Date createdOn = new Date(); | |
public Recipe() { | |
} | |
public Recipe(String recipeName, String description, String dishType, | |
String preparationTime, String cookingTime, String ingredients, String directions, | |
RecipeImage recipeImage) { | |
this.recipeName = recipeName; | |
this.description = description; | |
this.dishType = dishType; | |
this.preparationTime = preparationTime; | |
this.cookingTime = cookingTime; | |
this.ingredients = ingredients; | |
this.directions = directions; | |
this.recipeImage = recipeImage; | |
} | |
public int getId() { | |
return Id; | |
} | |
public String getRecipeName() { | |
return recipeName; | |
} | |
public String getDescription() { | |
return description; | |
} | |
public String getDishType() { | |
return dishType; | |
} | |
public String getPreparationTime() { | |
return preparationTime; | |
} | |
public String getCookingTime() { | |
return cookingTime; | |
} | |
public String getIngredients() { | |
return ingredients; | |
} | |
public String getDirections() { | |
return directions; | |
} | |
public RecipeImage getRecipeImage() { | |
return recipeImage; | |
} | |
public Date getCreatedOn() { | |
return createdOn; | |
} | |
public void setRecipeName(String recipeName) { | |
this.recipeName = recipeName; | |
} | |
public void setDescription(String description) { | |
this.description = description; | |
} | |
public void setDishType(String dishType) { | |
this.dishType = dishType; | |
} | |
public void setPreparationTime(String preparationTime) { | |
this.preparationTime = preparationTime; | |
} | |
public void setCookingTime(String cookingTime) { | |
this.cookingTime = cookingTime; | |
} | |
public void setIngredients(String ingredients) { | |
this.ingredients = ingredients; | |
} | |
public void setDirections(String directions) { | |
this.directions = directions; | |
} | |
public void setRecipeImage(RecipeImage recipeImage) { | |
this.recipeImage = recipeImage; | |
} | |
public String getImageName() { | |
return imageName; | |
} | |
public void setImageName(String imageName) { | |
this.imageName = imageName; | |
} | |
public String getImageURL() { | |
return imageURL; | |
} | |
public void setImageURL(String imageURL) { | |
this.imageURL = imageURL; | |
} | |
@Override | |
public String toString() { | |
return "Recipe [Id=" + Id + ", recipeName=" + recipeName + ", description=" + description + ", dishType=" | |
+ dishType + ", preparationTime=" + preparationTime + ", cookingTime=" + cookingTime + ", ingredients=" | |
+ ingredients + ", directions=" + directions + ", recipeImage=" + recipeImage + ", imageName=" | |
+ imageName + ", imageURL=" + imageURL + ", createdOn=" + createdOn + "]"; | |
} | |
} |
This will create a table in the database called RECIPE_DETAILS. We
store recipe information in the database table.
Repositories
Create a repository package and include the
IRecipeRepository interface in it. Include this code snippet:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.raven.springbootthymeleafrecipeorganizer.repository; | |
// imports are omitted | |
@Repository | |
public interface IRecipeRepository extends JpaRepository<Recipe, Integer> { | |
} |
Service to List Recipes
Make a package called service. Create the RecipeService class in
this package and include the following code snippet:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.raven.springbootthymeleafrecipeorganizer.service; | |
// imports are omitted | |
@Service | |
public class RecipeService { | |
private final IRecipeRepository recipeRepository; | |
@Autowired | |
public RecipeService(IRecipeRepository recipeRepository) { | |
this.recipeRepository = recipeRepository; | |
} | |
public List<Recipe> findAll() { | |
return this.recipeRepository.findAll(); | |
} | |
} |
We made this class a bean by annotating it with @Service and autowiring
IRecipeRepository with constructor injection. The
findAll() function will return all recipes in the database.
Controller to List Recipes
Create a package called controller, and then create the
RecipeController class:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.raven.springbootthymeleafrecipeorganizer.controller; | |
// imports are omitted | |
@Controller | |
@RequestMapping("/recipe") | |
public class RecipeController { | |
private final RecipeService recipeService; | |
@Autowired | |
public RecipeController(RecipeService recipeService) { | |
this.recipeService = recipeService; | |
} | |
@GetMapping("/list") | |
public ModelAndView getRecipeList() { | |
List<Recipe> recipes = new ArrayList<>(); | |
List<Recipe> recipeList = this.recipeService.findAll(); | |
recipeList.forEach(t -> { | |
t.setImageName(t.getRecipeImage().getOriginalFileName().toString()); | |
t.setImageURL(MvcUriComponentsBuilder | |
.fromMethodName(RecipeController.class, "getImage", | |
t.getRecipeImage().getOriginalFileName().toString()) | |
.build().toString()); | |
recipes.add(t); | |
}); | |
ModelAndView modelAndView = new ModelAndView("recipe/recipelist"); | |
modelAndView.addObject("recipes", recipes); | |
return modelAndView; | |
} | |
@GetMapping("/images/{fileName}") | |
public ResponseEntity<Resource> getImage(@PathVariable(name = "fileName") String fileName) { | |
Resource file = this.recipeService.loadImage(fileName); | |
return ResponseEntity.ok() | |
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getFilename() + "\"") | |
.body(file); | |
} | |
} |
This class has been annotated with the @Controller annotation to create
a Spring bean and autowired RecipeService using constructor injection.
We create a recipe list in getRecipeList() to obtain the image name and
URL. This image URL will be placed on the view page so it enables you to
preview the recipe image. Then, using the addObject() method, we insert
our recipe list object into the model with the attribute name recipes.
Because we specified recipe/recipelist as the view name in the
ModelAndView object, we must create recipelist.html in the
recipe directory.
Recipe List View Page
To list the recipes, we must now create a view page. To do so, create a
recipelist.html HTML page in the
/resources/templates/recipe directory:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<meta name="description" content="" /> | |
<title>Recipe Organizer</title> | |
<link rel="stylesheet" th:href="@{/css/style.css}" /> | |
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous" /> | |
<meta name="theme-color" content="#712cf9" /> | |
</head> | |
<body> | |
<header> | |
<div class="navbar navbar-dark bg-dark shadow-sm"> | |
<div class="container"> | |
<a | |
th:href="@{/recipe/list}" | |
class="navbar-brand d-flex align-items-center" | |
><strong>Recipe Organizer</strong> | |
</a> | |
<nav class="d-inline-flex mt-2 mt-md-0 ms-md-auto"> | |
<a class="me-3 py-2 text-dark text-decoration-none" href="#" | |
>Features</a | |
> | |
<a class="me-3 py-2 text-dark text-decoration-none" href="#" | |
>Enterprise</a | |
> | |
</nav> | |
</div> | |
</div> | |
</header> | |
<main> | |
<section | |
class="py-6 text-center container" | |
style="background-color: antiquewhite" | |
> | |
<div class="row py-lg-5"> | |
<div class="col-lg-6 col-md-8 mx-auto"> | |
<h1 class="fw-light">Recipe</h1> | |
<p class="text-muted"> | |
Recipe Organizer will help you keep better track of all your | |
favorite recipes. Once you’ve added all the recipes you love or | |
have been meaning to try, you can view them in the Recipe | |
Organizer as it currently is. | |
</p> | |
<p> | |
<a th:href="@{/recipe/new}" class="btn btn-primary my-2" | |
>New Recipe</a | |
> | |
</p> | |
</div> | |
</div> | |
</section> | |
<div class="album py-5 bg-light"> | |
<div class="container"> | |
<div | |
class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3" | |
data-masonry='{"percentPosition": true }' | |
> | |
<div class="col" th:each="recipe : ${recipes}"> | |
<div class="card shadow-sm"> | |
<img | |
th:src="@{${recipe.imageURL}}" | |
alt="${recipe.imageName}" | |
height="250" | |
width="100%" | |
/> | |
<div class="card-body"> | |
<a | |
th:href="@{'/recipe/view/' + ${recipe.Id}}" | |
style="text-decoration: none" | |
><h5 class="card-title" th:text="${recipe.recipeName}"></h5 | |
></a> | |
<p class="card-text" th:text="${recipe.description}"></p> | |
<div | |
class="d-flex justify-content-between align-items-center" | |
> | |
<div class="btn-group"> | |
<a | |
th:href="@{'/recipe/view/' + ${recipe.Id}}" | |
class="btn btn-sm btn-outline-secondary" | |
>View</a | |
> | |
<a | |
th:href="@{/##}" | |
class="btn btn-sm btn-outline-secondary" | |
>Edit</a | |
> | |
</div> | |
<small | |
class="text-muted" | |
th:text="${recipe.cookingTime}" | |
></small> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</main> | |
<script | |
src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" | |
integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" | |
crossorigin="anonymous" | |
></script> | |
<script | |
async | |
src="https://cdn.jsdelivr.net/npm/masonry-layout@4.2.2/dist/masonry.pkgd.min.js" | |
integrity="sha384-GNFwBvfVxBkLMJpYMOABq3c+d3KnQxudP/mGPkzpZSTYykLBNsZEnG2D9G/X/+7D" | |
crossorigin="anonymous" | |
></script> | |
</body> | |
</html> |
This page was created with Bootstrap. In this case, we used the model
attribute (recipes) to pair the data to the HTML.
We add th:href="@/recipe/new" in an anchor tag on this page to create a
new recipe. As a result, we must implement a get method for /new in the
controller. The @ symbol is used here to refer to our application's
context path.
Controller to Create New Recipe
In the RecipeController class, create newRecipe() and map it to
/new with the @GetMapping annotation:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.raven.springbootthymeleafrecipeorganizer.controller; | |
// imports are omitted | |
@Controller | |
@RequestMapping("/recipe") | |
public class RecipeController { | |
private final RecipeService recipeService; | |
@Autowired | |
public RecipeController(RecipeService recipeService) { | |
this.recipeService = recipeService; | |
} | |
@GetMapping("/new") | |
public ModelAndView newRecipe() { | |
ModelAndView modelAndView = new ModelAndView("recipe/newrecipe"); | |
modelAndView.addObject("recipe", new Recipe()); | |
return modelAndView; | |
} | |
@GetMapping("/list") | |
public ModelAndView getRecipeList() { | |
// existing code | |
} | |
} |
We created a ModelAndView object with the view name
recipe/newrecipe in the newRecipe() method. We passed a
Recipe object as an attribute to the ModelAndView object so that we
could bind it to the HTML form. For the view page, we must now create
newrecipe.html in the recipe directory.
New Recipe View Page
Create a newrecipe.html HTML page in the
/resources/templates/recipe directory:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<meta name="description" content="" /> | |
<title>Recipe Organizer</title> | |
<link rel="stylesheet" th:href="@{/css/style.css}" /> | |
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous" /> | |
<meta name="theme-color" content="#712cf9" /> | |
</head> | |
<body> | |
<header> | |
<div class="navbar navbar-dark bg-dark shadow-sm"> | |
<div class="container"> | |
<a th:href="@{/recipe/list}" class="navbar-brand d-flex align-items-center"><strong>Recipe Organizer</strong></a> | |
</div> | |
</div> | |
</header> | |
<div class="container"> | |
<hr /><h3>Recipe details</h3><hr /> | |
<form id="recipeForm" action="#" th:action="@{/recipe/save}" th:object="${recipe}" method="POST" enctype="multipart/form-data"> | |
<div class="row mb-3"> | |
<label class="col-sm-2 col-form-label">Recipe Name</label> | |
<div class="col-sm-6"> | |
<input type="text" class="form-control" th:field="*{recipeName}" required/> | |
</div> | |
</div> | |
<div class="row mb-3"> | |
<label class="col-sm-2 col-form-label">Description</label> | |
<div class="col-sm-8"> | |
<textarea class="form-control" rows="2" th:field="*{description}" required></textarea> | |
</div> | |
</div> | |
<div class="row mb-3"> | |
<label class="col-sm-2 col-form-label">Dish Type</label> | |
<div class="col-sm-3"> | |
<select class="form-select" th:field="*{dishType}" required> | |
<option th:value="select">Select...</option> | |
<option th:value="MainDish">MainDish</option> | |
<option th:value="Dessert">Dessert</option> | |
<option th:value="Salad">Salad</option> | |
<option th:value="Soup">Soup</option> | |
</select> | |
</div> | |
</div> | |
<div class="row mb-3"> | |
<label class="col-sm-2 col-form-label">Preparation Time</label> | |
<div class="col-sm-3"> | |
<input type="text" class="form-control" th:field="*{preparationTime}" required /> | |
</div> | |
</div> | |
<div class="row mb-3"> | |
<label class="col-sm-2 col-form-label">Cooking Time</label> | |
<div class="col-sm-3"> | |
<input type="text" class="form-control" th:field="*{cookingTime}" required /> | |
</div> | |
</div> | |
<div class="row mb-3"> | |
<label class="col-sm-2 col-form-label">Ingredients</label> | |
<div class="col-sm-8"> | |
<textarea class="form-control" rows="3" th:field="*{ingredients}" required></textarea> | |
</div> | |
</div> | |
<div class="row mb-3"> | |
<label class="col-sm-2 col-form-label">Directions</label> | |
<div class="col-sm-8"> | |
<textarea class="form-control" rows="3" th:field="*{directions}" required></textarea> | |
</div> | |
</div> | |
<div class="row mb-3"> | |
<label class="col-sm-2 col-form-label">Image</label> | |
<div class="col-sm-8"> | |
<input class="form-control" id="inputFile" type="file" name="inputFile" accept="image/png, image/jpeg"/> | |
</div> | |
</div> | |
<div class="row mb-3"> | |
<div class="col-sm-6"> | |
<img src="" id="imagePreview" height="350" width="100%" style="display: none"/> | |
</div> | |
</div> | |
<div class="col-10 text-end"> | |
<button type="submit" class="btn btn-info">Save</button> | |
</div> | |
</form> | |
<hr /> | |
</div> | |
<script | |
src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" | |
integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" | |
crossorigin="anonymous" | |
></script> | |
<script> | |
// TO PREVIEW UPLOADED IMAGE | |
var reader = new FileReader(); | |
reader.onload = function (r_event) { | |
document | |
.getElementById("imagePreview") | |
.setAttribute("src", r_event.target.result); | |
document.getElementById("imagePreview").style.display = "block"; | |
}; | |
document | |
.getElementsByName("inputFile")[0] | |
.addEventListener("change", function (event) { | |
reader.readAsDataURL(this.files[0]); | |
}); | |
</script> | |
</body> | |
</html> |
Using th:field="*..." we bind the attribute properties to the required
HTML controls. This form data will be posted to /recipe/save and is
mentioned in th:action="@...".
Service to Save New Recipe
We'll write some logic in the service class to save the recipe details. So, in
the RecipeService class, add the saveRecipe() method:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.raven.springbootthymeleafrecipeorganizer.service; | |
// imports are omitted | |
@Service | |
public class RecipeService { | |
private final String IMAGE_UPLOAD_DIR = "./image_uploads/"; | |
private final IRecipeRepository recipeRepository; | |
@Autowired | |
public RecipeService(IRecipeRepository recipeRepository) { | |
this.recipeRepository = recipeRepository; | |
} | |
public Recipe saveRecipe(Recipe _recipe, MultipartFile multipartFile) { | |
String fileName; | |
Recipe recipe = null; | |
Path filePath; | |
try { | |
fileName = StringUtils.cleanPath(Objects.requireNonNull(multipartFile.getOriginalFilename())); | |
String[] splitName = fileName.split("\\.(?=[^\\.]+$)"); | |
fileName = splitName[0].concat("_").concat(String.valueOf((new Date()).getTime())).concat(".") | |
.concat(splitName[1]); | |
filePath = Paths.get(IMAGE_UPLOAD_DIR + fileName); | |
Files.copy(multipartFile.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); | |
RecipeImage recipeImage = new RecipeImage(fileName, filePath.toString()); | |
_recipe.setRecipeImage(recipeImage); | |
recipe = this.recipeRepository.save(_recipe); | |
} catch (Exception e) { | |
e.printStackTrace(); | |
} | |
return recipe; | |
} | |
public List<Recipe> findAll() { | |
return this.recipeRepository.findAll(); | |
} | |
public Resource loadImage(String filename) { | |
try { | |
Path imageDirPath = Paths.get(this.IMAGE_UPLOAD_DIR); | |
Path file = imageDirPath.resolve(filename); | |
Resource resource = new UrlResource(file.toUri()); | |
if (resource.exists() || resource.isReadable()) { | |
return resource; | |
} else { | |
throw new RuntimeException("Could not read the image file."); | |
} | |
} catch (MalformedURLException e) { | |
throw new RuntimeException("Error: " + e.getMessage()); | |
} | |
} | |
} |
In the saveRecipe() method, we first save the image in a local
directory before saving the recipe details in the database alongside the image
details.
Controller to Save New Recipe
Now, in the RestController class, add the save() method and map
it to /save using @PostMapping:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.raven.springbootthymeleafrecipeorganizer.controller; | |
// imports are omitted | |
@Controller | |
@RequestMapping("/recipe") | |
public class RecipeController { | |
private final RecipeService recipeService; | |
@Autowired | |
public RecipeController(RecipeService recipeService) { | |
this.recipeService = recipeService; | |
} | |
@PostMapping("/save") | |
public String saveRecipe(@ModelAttribute("recipe") Recipe recipe, @RequestParam("inputFile") MultipartFile multipartFile) { | |
this.recipeService.saveRecipe(recipe, multipartFile); | |
return "redirect:/recipe/list"; | |
} | |
@GetMapping("/new") | |
public ModelAndView newRecipe() { | |
// existing code | |
} | |
@GetMapping("/list") | |
public ModelAndView getRecipeList() { | |
// existing code | |
} | |
} |
We simply use our service class's method to save the recipe details and
redirect the user to the recipe list page.
Service to Fetch Recipe Details
Now we'll write some logic to retrieve the details of a specific recipe in the
service class. In the RecipeService class, add the
findById() method:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.raven.springbootthymeleafrecipeorganizer.service; | |
// imports are omitted | |
@Service | |
public class RecipeService { | |
private final String IMAGE_UPLOAD_DIR = "./image_uploads/"; | |
private final IRecipeRepository recipeRepository; | |
@Autowired | |
public RecipeService(IRecipeRepository recipeRepository) { | |
this.recipeRepository = recipeRepository; | |
} | |
public Recipe findById(int _id) { | |
Optional<Recipe> _recipe = this.recipeRepository.findById(_id); | |
Recipe recipe; | |
if (_recipe.isPresent()) { | |
recipe = _recipe.get(); | |
} else { | |
throw new RuntimeException("No recipe with this id :: " + _id); | |
} | |
return recipe; | |
} | |
public List<Recipe> findAll() { | |
return this.recipeRepository.findAll(); | |
} | |
public Recipe saveRecipe(Recipe _recipe, MultipartFile multipartFile) { | |
String fileName; | |
Recipe recipe = null; | |
Path filePath; | |
try { | |
fileName = StringUtils.cleanPath(Objects.requireNonNull(multipartFile.getOriginalFilename())); | |
String[] splitName = fileName.split("\\.(?=[^\\.]+$)"); | |
fileName = splitName[0].concat("_").concat(String.valueOf((new Date()).getTime())).concat(".") | |
.concat(splitName[1]); | |
filePath = Paths.get(IMAGE_UPLOAD_DIR + fileName); | |
Files.copy(multipartFile.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); | |
RecipeImage recipeImage = new RecipeImage(fileName, filePath.toString()); | |
_recipe.setRecipeImage(recipeImage); | |
recipe = this.recipeRepository.save(_recipe); | |
} catch (Exception e) { | |
e.printStackTrace(); | |
} | |
return recipe; | |
} | |
public Resource loadImage(String filename) { | |
try { | |
Path imageDirPath = Paths.get(this.IMAGE_UPLOAD_DIR); | |
Path file = imageDirPath.resolve(filename); | |
Resource resource = new UrlResource(file.toUri()); | |
if (resource.exists() || resource.isReadable()) { | |
return resource; | |
} else { | |
throw new RuntimeException("Could not read the image file."); | |
} | |
} catch (MalformedURLException e) { | |
throw new RuntimeException("Error: " + e.getMessage()); | |
} | |
} | |
} |
Here, we retrieve the recipe details from the database using their
ID and then return them.
Controller to Fetch Recipe Details
In the RecipeController class, add the getRecipe() method and
map it to /view/id using the @GetMapping annotation:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.raven.springbootthymeleafrecipeorganizer.controller; | |
// imports are omitted | |
@Controller | |
@RequestMapping("/recipe") | |
public class RecipeController { | |
private final RecipeService recipeService; | |
@Autowired | |
public RecipeController(RecipeService recipeService) { | |
this.recipeService = recipeService; | |
} | |
@GetMapping("/view/{id}") | |
public ModelAndView getRecipe(@PathVariable(name = "id") int id) { | |
Recipe recipe = this.recipeService.findById(id); | |
recipe.setImageName(recipe.getRecipeImage().getOriginalFileName()); | |
recipe.setImageURL(MvcUriComponentsBuilder | |
.fromMethodName(RecipeController.class, "getImage", | |
recipe.getRecipeImage().getOriginalFileName()) | |
.build().toString()); | |
ModelAndView modelAndView = new ModelAndView("recipe/viewrecipe"); | |
modelAndView.addObject("recipe", recipe); | |
return modelAndView; | |
} | |
@GetMapping("/list") | |
public ModelAndView getRecipeList() { | |
List<Recipe> recipes = new ArrayList<>(); | |
List<Recipe> recipeList = this.recipeService.findAll(); | |
recipeList.forEach(t -> { | |
t.setImageName(t.getRecipeImage().getOriginalFileName()); | |
t.setImageURL(MvcUriComponentsBuilder | |
.fromMethodName(RecipeController.class, "getImage", | |
t.getRecipeImage().getOriginalFileName()) | |
.build().toString()); | |
recipes.add(t); | |
}); | |
ModelAndView modelAndView = new ModelAndView("recipe/recipelist"); | |
modelAndView.addObject("recipes", recipes); | |
return modelAndView; | |
} | |
@GetMapping("/new") | |
public ModelAndView newRecipe() { | |
ModelAndView modelAndView = new ModelAndView("recipe/newrecipe"); | |
modelAndView.addObject("recipe", new Recipe()); | |
return modelAndView; | |
} | |
@PostMapping("/save") | |
public String saveRecipe(@ModelAttribute("recipe") Recipe recipe, | |
@RequestParam("inputFile") MultipartFile multipartFile) { | |
Recipe savedRecipe = this.recipeService.saveRecipe(recipe, multipartFile); | |
return "redirect:/recipe/list"; | |
} | |
@GetMapping("/images/{fileName}") | |
public ResponseEntity<Resource> getImage(@PathVariable(name = "fileName") String fileName) { | |
Resource file = this.recipeService.loadImage(fileName); | |
return ResponseEntity.ok() | |
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getFilename() + "\"") | |
.body(file); | |
} | |
} |
In the getRecipe() method, we first use findById() to get the
recipe details, then we create the image URL and assign it to the recipe
details.
We need to create viewrecipe.html in the recipe directory
because we passed recipe/viewrecipe as our view name in the
ModelAndView object.
View Recipe Details Page
Now, in the /resources/templates/recipe directory, create an HTML page
called viewrecipe.html:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<meta name="description" content="" /> | |
<title>Recipe Organizer</title> | |
<link rel="stylesheet" th:href="@{/css/style.css}" /> | |
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous"/> | |
<meta name="theme-color" content="#712cf9" /> | |
</head> | |
<body> | |
<header> | |
<div class="navbar navbar-dark bg-dark shadow-sm"> | |
<div class="container"> | |
<a | |
th:href="@{/recipe/list}" | |
class="navbar-brand d-flex align-items-center" | |
><strong>Recipe Organizer</strong> | |
</a> | |
</div> | |
</div> | |
</header> | |
<div class="container"> | |
<form id="recipeForm" action="#" th:object="${recipe}"> | |
<hr /> | |
<p th:text="${recipe.recipeName}" class="fs-2 text-wrap"></p> | |
<hr /> | |
<div class="row mb-3"> | |
<div class="col-sm-6"> | |
<img | |
th:src="@{${recipe.imageURL}}" | |
alt="${recipe.imageName}" | |
height="350" | |
width="100%" | |
/> | |
</div> | |
<div class="col-sm-6"> | |
<p th:text="${recipe.description}" class="fs-5 text-wrap"></p> | |
</div> | |
</div> | |
<div class="row mb-3"> | |
<div class="col-sm-4"> | |
<label class="text-uppercase">Preparation Time</label> | |
<p th:text="${recipe.preparationTime}" class="fs-4 text-wrap"></p> | |
</div> | |
<div class="col-sm-4"> | |
<label class="text-uppercase">Cooking Time</label> | |
<p th:text="${recipe.cookingTime}" class="fs-4 text-wrap"></p> | |
</div> | |
<div class="col-sm-4"> | |
<label class="text-uppercase">Dish Type</label> | |
<p th:text="${recipe.dishType}" class="fs-4 text-wrap"></p> | |
</div> | |
</div> | |
<div class="row mb-3"> | |
<div class="col-sm-12"> | |
<label class="text-uppercase">Ingredients</label> | |
<p th:text="${recipe.ingredients}" class="fs-5"></p> | |
</div> | |
</div> | |
<div class="row mb-3"> | |
<div class="col-sm-12"> | |
<label class="text-uppercase">Directions</label> | |
<p th:text="${recipe.directions}" class="fs-5"></p> | |
</div> | |
</div> | |
<div class="col-sm-12"> | |
<a th:href="@{/recipe/list}" class="btn btn-sm btn-outline-secondary" | |
>back to list</a | |
> | |
</div> | |
</form> | |
<hr /> | |
</div> | |
<script | |
src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" | |
integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" | |
crossorigin="anonymous" | |
></script> | |
</body> | |
</html> |
Using th:object="$recipe" we link the attribute object to the HTML
form.
Recipe List Page
New Recipe Page
View Recipe Page
You can download the source code from
here.
Happy coding!!! 😊
No comments:
Post a Comment