Merge branch 'main' of gitlab.com:etc404/software-engineering-project

i didn't commit my decorative changes before create-html was updated
sorry
This commit is contained in:
kaipher7
2026-04-18 23:51:59 -06:00
23 changed files with 968 additions and 142 deletions
@@ -6,7 +6,6 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CsrfAuthenticationStrategy;
@Configuration @Configuration
public class SecurityConfig { public class SecurityConfig {
@@ -22,6 +21,7 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register", "/css/**", "/images/**").permitAll() .requestMatchers("/login", "/register", "/css/**", "/images/**").permitAll()
.requestMatchers("/api/users").permitAll() .requestMatchers("/api/users").permitAll()
.requestMatchers("/users/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated() .anyRequest().authenticated()
) )
@@ -0,0 +1,54 @@
package com.example.demo.controller;
import java.security.Principal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import com.example.demo.dto.ProfileDto;
import com.example.demo.dto.UpdateProfileDto;
import com.example.demo.service.UserService;
@Controller
public class ProfileController {
private final UserService userService;
public ProfileController(UserService userService) {
this.userService = userService;
}
@GetMapping("/users/{id}")
public String viewPublicProfile(@PathVariable Integer id, Model model) {
ProfileDto profile = userService.getProfileByUserId(id);
model.addAttribute("profile", profile);
return "public-profile";
}
@GetMapping("/my-profile")
public String viewMyProfile(Principal principal, Model model) {
String username = principal.getName();
ProfileDto profile = userService.getCurrentUserProfile(username);
UpdateProfileDto updateProfileDto = new UpdateProfileDto();
updateProfileDto.setDisplayName(profile.getDisplayName());
updateProfileDto.setBio(profile.getBio());
model.addAttribute("profile", profile);
model.addAttribute("updateProfileDto", updateProfileDto);
return "my-profile";
}
@PostMapping("/my-profile/update")
public String updateMyProfile(@ModelAttribute UpdateProfileDto dto, Principal principal) {
String username = principal.getName();
userService.updateProfile(username, dto);
return "redirect:/my-profile";
}
}
@@ -36,7 +36,8 @@ public class RecipeController {
// build create recipe REST API // build create recipe REST API
@PostMapping @PostMapping
public ResponseEntity<RecipeDto> saveRecipe(@RequestBody RecipeDto recipeDto, Authentication authentication) { public ResponseEntity<RecipeDto> saveRecipe(@Valid @RequestBody Recipe recipe, Authentication authentication) {
RecipeDto recipeDto = recipeService.convertToDto(recipe);
String currentUsername = authentication.getName(); String currentUsername = authentication.getName();
return new ResponseEntity<>(recipeService.saveRecipe(recipeDto, currentUsername), HttpStatus.CREATED); return new ResponseEntity<>(recipeService.saveRecipe(recipeDto, currentUsername), HttpStatus.CREATED);
} }
@@ -58,12 +59,15 @@ public class RecipeController {
@GetMapping("/search") @GetMapping("/search")
public ResponseEntity<List<RecipeDto>> searchRecipes( public ResponseEntity<List<RecipeDto>> searchRecipes(
@RequestParam(required = false) String name, // by not adding a name all recipes will appear basically @RequestParam(required = false) String name, // by not adding a name all recipes will appear basically
@RequestParam(required = false) List<String> tags // since users can choose no tags this isnt required @RequestParam(required = false) List<String> tags, // since users can choose no tags this isnt required
@RequestParam(required = false) List<Integer> prices,
@RequestParam(required = false) List<Integer> cookTime,
@RequestParam(required = false) List<Integer> prepTime
) { ) {
List<RecipeDto> recipes = recipeService.getRecipes(name, tags); List<RecipeDto> recipes = recipeService.getRecipes(name, tags, prices, cookTime, prepTime);
return new ResponseEntity<>(recipes, HttpStatus.OK); return new ResponseEntity<>(recipes, HttpStatus.OK);
} }
@@ -2,14 +2,13 @@ package com.example.demo.controller;
import java.util.List; import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.demo.service.RecipeService; import com.example.demo.service.RecipeService;
import com.example.demo.dto.RecipeDto; import com.example.demo.dto.RecipeDto;
import com.example.demo.entity.Recipe;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
@Controller @Controller
public class SiteController { public class SiteController {
@@ -19,9 +18,9 @@ public class SiteController {
public SiteController(RecipeService recipeService) { public SiteController(RecipeService recipeService) {
this.recipeService = recipeService; this.recipeService = recipeService;
} }
@GetMapping("/") @GetMapping("/")
public String viewHomePage(Model model) { public String viewHomePage(Model model) {
//model.addAttribute("allemplist", employeeServiceImpl.getAllEmployee());
List<RecipeDto> recipes = recipeService.getAllRecipes(); List<RecipeDto> recipes = recipeService.getAllRecipes();
model.addAttribute("recipes", recipes); model.addAttribute("recipes", recipes);
return "home"; return "home";
@@ -49,12 +48,26 @@ public class SiteController {
return "view-recipe"; return "view-recipe";
} }
@GetMapping("/explore") @GetMapping("/recipes/{id}/edit")
public String viewExplorePage(Model model) { public String viewEditRecipePage(@PathVariable Integer id, Model model) {
//model.addAttribute("allemplist", employeeServiceImpl.getAllEmployee()); RecipeDto recipe = recipeService.getRecipeById(id);
List<RecipeDto> recipes = recipeService.getAllRecipes(); model.addAttribute("recipe", recipe);
model.addAttribute("recipes", recipes); return "update-recipe";
return "explore";
} }
@GetMapping("/explore")
public String explore(
@RequestParam(required = false) String q,
@RequestParam(required = false) List<String> tags,
@RequestParam(required = false) List<Integer> prices,
@RequestParam(required = false) List<Integer> cookTime,
@RequestParam(required = false) List<Integer> prepTime,
Model model
) {
List<RecipeDto> recipes = recipeService.getRecipes(q, tags, prices, cookTime, prepTime);
model.addAttribute("recipes", recipes);
model.addAttribute("q", q);
model.addAttribute("tags", tags);
return "explore";
}
} }
@@ -4,8 +4,11 @@ import java.security.Principal;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -34,10 +37,10 @@ public class UserController {
this.userRepo = userRepo; this.userRepo = userRepo;
} }
// build create user REST API // build create user REST API
@PostMapping @PostMapping
public ResponseEntity<User> saveUser(@RequestBody User user) { public ResponseEntity<User> saveUser(@RequestBody User user) {
return new ResponseEntity<User>(userService.saveUser(user), HttpStatus.CREATED); return new ResponseEntity<User>(userService.saveUser(user), HttpStatus.CREATED);
} }
@@ -0,0 +1,61 @@
package com.example.demo.dto;
import java.util.List;
public class ProfileDto {
private Integer id;
private String username;
private String displayName;
private String bio;
private List<RecipeDto> recipes;
public ProfileDto() {
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getBio() {
return bio;
}
public void setBio(String bio) {
this.bio = bio;
}
public List<RecipeDto> getRecipes() {
return recipes;
}
public void setRecipes(List<RecipeDto> recipes) {
this.recipes = recipes;
}
public String getEffectiveDisplayName() {
if (displayName != null && !displayName.isBlank()) {
return displayName;
}
return username;
}
}
@@ -17,6 +17,7 @@ public class RecipeDto {
private List<StepDto> steps; private List<StepDto> steps;
private List<ImageDto> images; private List<ImageDto> images;
private List<TagDto> tags; private List<TagDto> tags;
private Integer cost;
public RecipeDto() { public RecipeDto() {
super(); super();
@@ -24,7 +25,7 @@ public class RecipeDto {
public RecipeDto(String title, String description, Integer prepTimeMinutes, Integer cookTimeMinutes, public RecipeDto(String title, String description, Integer prepTimeMinutes, Integer cookTimeMinutes,
Integer servings, UserDto userDto, String status, List<RecipeIngredientDto> ingredients, Integer servings, UserDto userDto, String status, List<RecipeIngredientDto> ingredients,
List<StepDto> steps, List<ImageDto> images, List<TagDto> tags) { List<StepDto> steps, List<ImageDto> images, List<TagDto> tags, Integer cost) {
super(); super();
this.title = title; this.title = title;
this.description = description; this.description = description;
@@ -37,6 +38,7 @@ public class RecipeDto {
this.steps = steps; this.steps = steps;
this.images = images; this.images = images;
this.tags = tags; this.tags = tags;
this.cost = cost;
} }
// getters and setters // getters and setters
@@ -136,4 +138,14 @@ public class RecipeDto {
public void setId(Integer id) { public void setId(Integer id) {
this.id = id; this.id = id;
} }
public Integer getCost() {
return cost;
}
public void setCost(Integer cost) {
this.cost = cost;
}
} }
@@ -0,0 +1,25 @@
package com.example.demo.dto;
public class UpdateProfileDto {
private String displayName;
private String bio;
public UpdateProfileDto() {
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getBio() {
return bio;
}
public void setBio(String bio) {
this.bio = bio;
}
}
@@ -4,6 +4,8 @@ public class UserDto {
private Integer id; private Integer id;
private String username; private String username;
private String email; private String email;
private String displayName;
private String bio;
public UserDto() { public UserDto() {
} }
@@ -14,6 +16,14 @@ public class UserDto {
this.email = email; this.email = email;
} }
public UserDto(Integer id, String username, String email, String displayName, String bio) {
this.id = id;
this.username = username;
this.email = email;
this.displayName = displayName;
this.bio = bio;
}
public Integer getId() { public Integer getId() {
return id; return id;
} }
@@ -38,4 +48,26 @@ public class UserDto {
this.email = email; this.email = email;
} }
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getBio() {
return bio;
}
public void setBio(String bio) {
this.bio = bio;
}
public String getEffectiveDisplayName() {
if (displayName != null && !displayName.isBlank()) {
return displayName;
}
return username;
}
} }
@@ -40,6 +40,10 @@ public class Recipe {
private String status; private String status;
@NotNull(message = "Please Provide a cost")
@Positive(message = "This value cannot be negative")
private Integer cost;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@@ -76,7 +80,7 @@ public class Recipe {
} }
public Recipe(String title, String description, Integer prepTimeMinutes, Integer cookTimeMinutes, Integer servings, public Recipe(String title, String description, Integer prepTimeMinutes, Integer cookTimeMinutes, Integer servings,
User user, String status) { User user, String status, Integer cost) {
this.title = title; this.title = title;
this.description = description; this.description = description;
this.prepTimeMinutes = prepTimeMinutes; this.prepTimeMinutes = prepTimeMinutes;
@@ -86,6 +90,7 @@ public class Recipe {
this.status = status; this.status = status;
this.createdAt = LocalDateTime.now(); this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now(); this.updatedAt = LocalDateTime.now();
this.cost = cost;
} }
// Getters and setters // Getters and setters
@@ -208,4 +213,12 @@ public class Recipe {
public void setUsers(Set<User> users) { public void setUsers(Set<User> users) {
this.users = users; this.users = users;
} }
public Integer getCost() {
return cost;
}
public void setCost(Integer cost) {
this.cost = cost;
}
} }
@@ -34,6 +34,12 @@ public class User implements UserDetails {
@Column(nullable = false, unique = true) @Column(nullable = false, unique = true)
private String username; private String username;
@Column(length = 100)
private String displayName;
@Column(length = 1000)
private String bio;
@Column(nullable = false) @Column(nullable = false)
private String role; private String role;
@@ -118,6 +124,22 @@ public class User implements UserDetails {
this.username = username; this.username = username;
} }
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getBio() {
return bio;
}
public void setBio(String bio) {
this.bio = bio;
}
public String getRole() { public String getRole() {
return role; return role;
} }
@@ -173,4 +195,11 @@ public class User implements UserDetails {
public void setFavRecipes(Set<Recipe> favRecipes) { public void setFavRecipes(Set<Recipe> favRecipes) {
this.FavRecipes = favRecipes; this.FavRecipes = favRecipes;
} }
public String getEffectiveDisplayName() {
if (displayName != null && !displayName.isBlank()) {
return displayName;
}
return username;
}
} }
@@ -71,12 +71,17 @@ public class RecipeServiceImpl implements RecipeService {
List<TagDto> tagDtos = recipe.getTags().stream().map(ri -> new TagDto(ri.getName())).toList(); List<TagDto> tagDtos = recipe.getTags().stream().map(ri -> new TagDto(ri.getName())).toList();
UserDto userDto = new UserDto(recipe.getUser().getId(), recipe.getUser().getUsername(), UserDto userDto = new UserDto(
recipe.getUser().getEmail()); recipe.getUser().getId(),
recipe.getUser().getUsername(),
recipe.getUser().getEmail(),
recipe.getUser().getDisplayName(),
recipe.getUser().getBio()
);
RecipeDto dto = new RecipeDto(recipe.getTitle(), recipe.getDescription(), recipe.getPrepTimeMinutes(), RecipeDto dto = new RecipeDto(recipe.getTitle(), recipe.getDescription(), recipe.getPrepTimeMinutes(),
recipe.getCookTimeMinutes(), recipe.getServings(), userDto, recipe.getStatus(), ingredientDtos, recipe.getCookTimeMinutes(), recipe.getServings(), userDto, recipe.getStatus(), ingredientDtos,
stepDtos, imageDtos, tagDtos); stepDtos, imageDtos, tagDtos, recipe.getCost());
dto.setId(recipe.getId()); dto.setId(recipe.getId());
@@ -130,7 +135,7 @@ public class RecipeServiceImpl implements RecipeService {
enforceUploadLimit(currentUser); enforceUploadLimit(currentUser);
Recipe recipe = new Recipe(dto.getTitle(), dto.getDescription(), dto.getPrepTimeMinutes(), Recipe recipe = new Recipe(dto.getTitle(), dto.getDescription(), dto.getPrepTimeMinutes(),
dto.getCookTimeMinutes(), dto.getServings(), currentUser, dto.getStatus()); dto.getCookTimeMinutes(), dto.getServings(), currentUser, dto.getStatus(), dto.getCost());
if (dto.getIngredients() != null) { if (dto.getIngredients() != null) {
for (RecipeIngredientDto riDto : dto.getIngredients()) { for (RecipeIngredientDto riDto : dto.getIngredients()) {
@@ -146,6 +151,7 @@ public class RecipeServiceImpl implements RecipeService {
riDto.getNotes()); riDto.getNotes());
recipe.getRecipeIngredients().add(ri); recipe.getRecipeIngredients().add(ri);
} }
} }
@@ -216,6 +222,7 @@ public class RecipeServiceImpl implements RecipeService {
existingRecipe.setCookTimeMinutes(recipeDto.getCookTimeMinutes()); existingRecipe.setCookTimeMinutes(recipeDto.getCookTimeMinutes());
existingRecipe.setServings(recipeDto.getServings()); existingRecipe.setServings(recipeDto.getServings());
existingRecipe.setStatus(recipeDto.getStatus()); existingRecipe.setStatus(recipeDto.getStatus());
existingRecipe.setCost(recipeDto.getCost());
List<RecipeIngredientDto> updatedIngredients = recipeDto.getIngredients(); List<RecipeIngredientDto> updatedIngredients = recipeDto.getIngredients();
List<RecipeIngredient> ingredientsToRemove = new ArrayList<>(); List<RecipeIngredient> ingredientsToRemove = new ArrayList<>();
@@ -369,11 +376,11 @@ public class RecipeServiceImpl implements RecipeService {
@Override @Override
@Transactional @Transactional
public List<RecipeDto> getRecipes(String name, List<String> tags) { public List<RecipeDto> getRecipes(String name, List<String> tags, List<Integer> prices, List<Integer> cookTime, List<Integer> prepTime) {
List<Recipe> recipes; List<Recipe> recipes;
if (!name.isBlank()) { if ((name != null) && (!name.isBlank())) {
recipes = recipeRepository.findByTitleContainingIgnoreCase(name); recipes = recipeRepository.findByTitleContainingIgnoreCase(name);
} }
@@ -381,12 +388,50 @@ public class RecipeServiceImpl implements RecipeService {
recipes = recipeRepository.findAll(); recipes = recipeRepository.findAll();
} }
if (!tags.isEmpty() && !recipes.isEmpty()) { if ((tags != null) && (!tags.isEmpty()) && !recipes.isEmpty()) {
recipes = recipes.stream() recipes = recipes.stream()
.filter(recipe -> recipe.getTags().stream().anyMatch(tag -> tags.contains(tag.getName()))) .filter(recipe -> recipe.getTags().stream().anyMatch(tag -> tags.contains(tag.getName())))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
if (prices != null && !prices.isEmpty() && !recipes.isEmpty()) {
recipes = recipes.stream()
.filter(recipe -> prices.contains(recipe.getCost()))
.collect(Collectors.toList());
}
if (cookTime != null && !cookTime.isEmpty() && !recipes.isEmpty()) {
recipes = recipes.stream()
.filter(recipe -> {
int minutes = recipe.getCookTimeMinutes();
for (Integer ct : cookTime) {
if (ct == 15 && minutes <= 15) return true;
if (ct == 30 && minutes > 15 && minutes <= 30) return true;
if (ct == 60 && minutes > 30 && minutes <= 60) return true;
if (ct == 120 && minutes > 60 && minutes <= 120) return true;
if (ct == 121 && minutes > 120) return true;
}
return false;
})
.collect(Collectors.toList());
}
if (prepTime != null && !prepTime.isEmpty() && !recipes.isEmpty()) {
recipes = recipes.stream()
.filter(recipe -> {
int minutes = recipe.getPrepTimeMinutes();
for (Integer ct : prepTime) {
if (ct == 15 && minutes <= 15) return true;
if (ct == 30 && minutes > 15 && minutes <= 30) return true;
if (ct == 60 && minutes > 30 && minutes <= 60) return true;
if (ct == 240 && minutes > 60 && minutes <= 240) return true;
if (ct == 241 && minutes > 240) return true;
}
return false;
})
.collect(Collectors.toList());
}
List<RecipeDto> recipeList = new ArrayList<>(); List<RecipeDto> recipeList = new ArrayList<>();
for (Recipe recipe : recipes) { for (Recipe recipe : recipes) {
@@ -2,21 +2,20 @@ package com.example.demo.service.Impl;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.example.demo.dto.ImageDto; import com.example.demo.dto.ProfileDto;
import com.example.demo.dto.RecipeDto; import com.example.demo.dto.UpdateProfileDto;
import com.example.demo.dto.RecipeIngredientDto;
import com.example.demo.dto.StepDto;
import com.example.demo.dto.TagDto;
import com.example.demo.dto.UserDto; import com.example.demo.dto.UserDto;
import com.example.demo.dto.RecipeDto;
import com.example.demo.entity.Recipe; import com.example.demo.entity.Recipe;
import com.example.demo.entity.User; import com.example.demo.entity.User;
import com.example.demo.exception.NotFoundException; import com.example.demo.exception.NotFoundException;
import com.example.demo.repository.RecipeRepo; import com.example.demo.repository.RecipeRepo;
import com.example.demo.repository.UserRepo; import com.example.demo.repository.UserRepo;
import com.example.demo.service.RecipeService;
import com.example.demo.service.UserService; import com.example.demo.service.UserService;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
@@ -26,15 +25,27 @@ public class UserServiceImpl implements UserService {
private UserRepo userRepository; private UserRepo userRepository;
private RecipeRepo recipeRepository; private RecipeRepo recipeRepository;
private PasswordEncoder passwordEncoder;
private RecipeService recipeService;
public UserServiceImpl(UserRepo userRepository, RecipeRepo recipeRepository) { public UserServiceImpl(UserRepo userRepository, RecipeRepo recipeRepository,
PasswordEncoder passwordEncoder, RecipeService recipeService) {
super(); super();
this.userRepository = userRepository; this.userRepository = userRepository;
this.recipeRepository = recipeRepository; this.recipeRepository = recipeRepository;
this.passwordEncoder = passwordEncoder;
this.recipeService = recipeService;
} }
@Override
public UserDto convertToDto(User user) { public UserDto convertToDto(User user) {
return new UserDto(user.getId(), user.getUsername(), user.getEmail()); return new UserDto(
user.getId(),
user.getUsername(),
user.getEmail(),
user.getDisplayName(),
user.getBio()
);
} }
@Override @Override
@@ -43,25 +54,24 @@ public class UserServiceImpl implements UserService {
user.setRole("ROLE_USER"); user.setRole("ROLE_USER");
} }
user.setBanned(false); user.setBanned(false);
user.setHashedpassword(passwordEncoder.encode(user.getPassword()));
return userRepository.save(user); return userRepository.save(user);
} }
@Override @Override
public List<UserDto> getAllUsers() { public List<UserDto> getAllUsers() {
List<UserDto> list = new ArrayList<>(); List<UserDto> list = new ArrayList<>();
for (User user : userRepository.findAll()) { for (User user : userRepository.findAll()) {
UserDto userDto = convertToDto(user); UserDto userDto = convertToDto(user);
list.add(userDto); list.add(userDto);
} }
return list; return list;
} }
@Override @Override
public UserDto getUserById(Integer Id) { public UserDto getUserById(Integer id) {
return convertToDto(userRepository.findById(id)
return convertToDto(userRepository.findById(Id).orElseThrow(() -> new NotFoundException("User", "id", Id))); .orElseThrow(() -> new NotFoundException("User", "id", id)));
} }
@Override @Override
@@ -80,9 +90,9 @@ public class UserServiceImpl implements UserService {
} }
@Override @Override
public UserDto updateUser(User user, Integer Id) { public UserDto updateUser(User user, Integer id) {
User existingUser = userRepository.findById(id)
User existingUser = userRepository.findById(Id).orElseThrow(() -> new NotFoundException("User", "id", Id)); .orElseThrow(() -> new NotFoundException("User", "id", id));
existingUser.setUsername(user.getUsername()); existingUser.setUsername(user.getUsername());
existingUser.setEmail(user.getEmail()); existingUser.setEmail(user.getEmail());
@@ -93,9 +103,10 @@ public class UserServiceImpl implements UserService {
} }
@Override @Override
public void deleteUser(Integer Id) { public void deleteUser(Integer id) {
userRepository.findById(Id).orElseThrow(() -> new NotFoundException("User", "id", Id)); userRepository.findById(id)
userRepository.deleteById(Id); .orElseThrow(() -> new NotFoundException("User", "id", id));
userRepository.deleteById(id);
} }
@Override @Override
@@ -106,10 +117,9 @@ public class UserServiceImpl implements UserService {
Recipe existingRecipe = recipeRepository.findById(recipeId) Recipe existingRecipe = recipeRepository.findById(recipeId)
.orElseThrow(() -> new NotFoundException("Recipe", "id", recipeId)); .orElseThrow(() -> new NotFoundException("Recipe", "id", recipeId));
userRepository.save(existingUser);
existingUser.getFavRecipes().remove(existingRecipe); existingUser.getFavRecipes().remove(existingRecipe);
userRepository.save(existingUser);
} }
@Override @Override
@@ -164,4 +174,50 @@ public class UserServiceImpl implements UserService {
userRepository.save(user); userRepository.save(user);
return convertToDto(user); return convertToDto(user);
} }
@Override
@Transactional
public ProfileDto getProfileByUserId(Integer userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("User", "id", userId));
List<Recipe> recipes = recipeRepository.findByUserId(userId);
List<RecipeDto> recipeDtos = new ArrayList<>();
for (Recipe recipe : recipes) {
recipeDtos.add(recipeService.convertToDto(recipe));
}
ProfileDto profile = new ProfileDto();
profile.setId(user.getId());
profile.setUsername(user.getUsername());
profile.setDisplayName(user.getDisplayName());
profile.setBio(user.getBio());
profile.setRecipes(recipeDtos);
return profile;
}
@Override
@Transactional
public ProfileDto getCurrentUserProfile(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new NotFoundException("User", "username", username));
return getProfileByUserId(user.getId());
}
@Override
@Transactional
public ProfileDto updateProfile(String username, UpdateProfileDto dto) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new NotFoundException("User", "username", username));
user.setDisplayName(dto.getDisplayName());
user.setBio(dto.getBio());
userRepository.save(user);
return getProfileByUserId(user.getId());
}
} }
@@ -17,7 +17,7 @@ public interface RecipeService {
RecipeDto getRecipeById(Integer recipeId); RecipeDto getRecipeById(Integer recipeId);
List<RecipeDto> getRecipes(String name, List<String> tags); List<RecipeDto> getRecipes(String name, List<String> tags, List<Integer> prices, List<Integer> cookTime, List<Integer> prepTime);
RecipeDto updateRecipe(RecipeDto recipedto, Integer Id, String currentUsername); RecipeDto updateRecipe(RecipeDto recipedto, Integer Id, String currentUsername);
@@ -2,6 +2,8 @@ package com.example.demo.service;
import java.util.List; import java.util.List;
import com.example.demo.dto.ProfileDto;
import com.example.demo.dto.UpdateProfileDto;
import com.example.demo.dto.UserDto; import com.example.demo.dto.UserDto;
import com.example.demo.entity.User; import com.example.demo.entity.User;
@@ -31,4 +33,10 @@ public interface UserService {
UserDto makeAdmin(Integer id); UserDto makeAdmin(Integer id);
UserDto makeUser(Integer id); UserDto makeUser(Integer id);
ProfileDto getProfileByUserId(Integer userId);
ProfileDto getCurrentUserProfile(String username);
ProfileDto updateProfile(String username, UpdateProfileDto dto);
} }
+75 -11
View File
@@ -191,28 +191,22 @@ body, html {
.main-content { .main-content {
width: 100%; width: 100%;
flex-grow: 1; flex-grow: 1;
display: flex; /*display: flex; this line was breaking the searched results. They returned recipes would not load at the top of the page*/
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: flex-start; align-items: flex-start;
overflow: scroll; overflow: auto;
scrollbar-color: var(--dusty-red) var(--pale-yellow); scrollbar-color: var(--dusty-red) var(--pale-yellow);
height: 100%; height: auto;
} }
/* safari and old browsers*/
::-webkit-scrollbar-track {
background: var(--pale-yellow);
}
::-webkit-scrollbar-thumb {
background: var(--dusty-red);
}
/* ========================= /* =========================
Search Bar Search Bar
========================= */ ========================= */
.search-bar, input[type="search"] { .search-bar, input[type="search"] {
width: 90%; /* width: 90%; */
margin: 10px; margin: 10px;
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
@@ -266,10 +260,76 @@ input[type="search"]::-webkit-search-cancel-button {
} }
#tag-input-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
background: var(--dusty-red);
border-radius: 10px;
padding: 6px 10px;
min-height: 50px;
flex: 1;
cursor: text;
}
#tag-input-wrapper input[type="text"] {
flex: 1;
min-width: 140px;
background: transparent;
border: none;
outline: none;
color: var(--dark-yellow);
font-size: 20px;
font-family: 'Mali', cursive;
font-weight: 600;
height: auto;
padding: 0;
margin: 0;
width: auto;
}
#chips-container {
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--dark);
min-height: 32px;
color: var(--dark-yellow);
border-radius: 20px;
padding: 2px 8px 3px 12px;
font-size: 0.75em;
font-weight: 700;
white-space: nowrap;
}
.tag-chip button {
background: none;
border: none;
color: var(--dark-yellow);
cursor: pointer;
padding: 0;
font-size: 1.4em;
line-height: 1;
opacity: 0.8;
font-family: 'Mali', cursive;
font-weight: 800;
}
.tag-chip button:hover { opacity: 1; }
/* ========================= /* =========================
Recipe Cards Layout Recipe Cards Layout
========================= */ ========================= */
.recipe-card { .recipe-card {
padding-top: 20px;
margin-top: 35px; margin-top: 35px;
width: 99.5%; width: 99.5%;
display: flex; display: flex;
@@ -279,8 +339,12 @@ input[type="search"]::-webkit-search-cancel-button {
flex-direction: row; flex-direction: row;
height: fit-content; height: fit-content;
padding-right: 10px; padding-right: 10px;
overflow-y: auto;
flex: 1;
scrollbar-color: var(--dusty-red) var(--pale-yellow);
} }
a { a {
text-decoration: none; text-decoration: none;
color: var(--dark); color: var(--dark);
@@ -2,6 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta charset="UTF-8">
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
<meta name="_csrf" th:content="${_csrf.token}"/>
<title>Create Thyme Crunch Account</title> <title>Create Thyme Crunch Account</title>
<link rel="stylesheet" th:href="@{css/create-account.css}"> <link rel="stylesheet" th:href="@{css/create-account.css}">
<link href="https://fonts.googleapis.com/css2?family=Delius+Swash+Caps&family=Mali:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Delius+Swash+Caps&family=Mali:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700" rel="stylesheet">
@@ -57,34 +60,39 @@
<script> <script>
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const form = document.getElementById("createUserForm");
const passwordField = document.getElementById("password"); const passwordField = document.getElementById("password");
const confirmPasswordField = document.getElementById("confirmPassword"); const confirmPasswordField = document.getElementById("confirmPassword");
const passwordError = document.getElementById("passwordError");
function checkPasswords() { function checkPasswords() {
if (confirmPasswordField.value === "") { if (confirmPasswordField.value === "") {
confirmPasswordField.classList.remove("invalid"); confirmPasswordField.classList.remove("invalid");
passwordError.textContent = "";
return; return;
} }
if (passwordField.value !== confirmPasswordField.value) { if (passwordField.value !== confirmPasswordField.value) {
confirmPasswordField.classList.add("invalid"); confirmPasswordField.classList.add("invalid");
passwordError.textContent = "Passwords do not match.";
} else { } else {
confirmPasswordField.classList.remove("invalid"); confirmPasswordField.classList.remove("invalid");
passwordError.textContent = "";
} }
} }
passwordField.addEventListener("input", checkPasswords); passwordField.addEventListener("input", checkPasswords);
confirmPasswordField.addEventListener("input", checkPasswords); confirmPasswordField.addEventListener("input", checkPasswords);
document.getElementById("createUserForm").addEventListener("submit", function(e) { form.addEventListener("submit", async function(e) {
e.preventDefault();
const password = passwordField.value; const password = passwordField.value;
const confirmPassword = confirmPasswordField.value; const confirmPassword = confirmPasswordField.value;
if (password !== confirmPassword) { if (password !== confirmPassword) {
e.preventDefault();
confirmPasswordField.classList.add("invalid"); confirmPasswordField.classList.add("invalid");
passwordError.textContent = "Passwords do not match.";
return; return;
} }
@@ -92,18 +100,39 @@
username: document.getElementById("username").value, username: document.getElementById("username").value,
email: document.getElementById("email").value, email: document.getElementById("email").value,
hashedpassword: password, hashedpassword: password,
role: "USER" role: "ROLE_USER"
}; };
fetch("http://localhost:8080/api/users", { const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
try {
const response = await fetch("/api/users", {
method: "POST", method: "POST",
headers: { headers: {
[csrfHeader]: csrfToken,
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
body: JSON.stringify(userData) body: JSON.stringify(userData)
}); });
e.preventDefault(); if (response.ok) {
passwordError.style.color = "green";
passwordError.textContent = "Account created successfully. Redirecting to login...";
setTimeout(function () {
window.location.href = "/login";
}, 1500);
} else {
const errorText = await response.text();
passwordError.style.color = "red";
passwordError.textContent = "Account creation failed. Please try a different username or email.";
console.error("Create account failed:", errorText);
}
} catch (error) {
passwordError.style.color = "red";
passwordError.textContent = "Could not connect to the server.";
console.error("Request error:", error);
}
}); });
}); });
@@ -249,6 +249,7 @@ function buildRecipeJSON(user) {
const cookTimeMinutes = Number(document.getElementById('cooking').value); const cookTimeMinutes = Number(document.getElementById('cooking').value);
const servings = Number(document.getElementById('servings').value); const servings = Number(document.getElementById('servings').value);
const status = "DRAFT"; const status = "DRAFT";
const cost = Number(document.getElementById('cost').value);
// Ingredients // Ingredients
const recipeIngredients = [...document.querySelectorAll('#ingredients-container .dynamic-row')] const recipeIngredients = [...document.querySelectorAll('#ingredients-container .dynamic-row')]
@@ -282,6 +283,7 @@ function buildRecipeJSON(user) {
cookTimeMinutes, cookTimeMinutes,
servings, servings,
status, status,
cost,
user, user,
recipeIngredients, recipeIngredients,
steps, steps,
+177 -57
View File
@@ -1,75 +1,195 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" xmlns:th="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Thyme Crunch Home</title> <title>Explore Recipes</title>
<link rel="stylesheet" th:href="@{css/explore.css}">
<link href="https://fonts.googleapis.com/css2?family=Delius+Swash+Caps&family=Mali:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
</head> </head>
<body> <body>
<h1>Explore Recipes</h1>
<header class="top-header"> <nav>
<img th:src="@{images/header_left.png}" alt="Violin f-hole shape to the left of header." class="swirl"> <a th:href="@{/}">Home</a> |
<h1 class="site-name">Thyme Crunch</h1> <a th:href="@{/explore}">Explore</a> |
<img th:src="@{images/header_right.png}" alt="Violin f-hole shape to the right of header." class="swirl"> <a th:href="@{/my-profile}">Profile</a>
</header>
<div class="body">
<!--Navigation Bar -->
<div class="body-left">
<nav class="sidebar-left">
<ul>
<li><a href="/">Home</a></li>
<li><a href="#">Explore</a></li>
<li><a href="#">Profile</a></li>
<li><a href="#">Saved</a></li>
<li>
<form th:action="@{/logout}" method="post">
<input type="image" th:src="@{images/logout_icon.png}" alt="Logout button" class="nav_icon"/>
</form>
</li>
</ul>
</nav> </nav>
<a th:href="@{/create}" target="_blank" class="create_icon">
<img th:src="@{images/create_icon.png}" alt="Create New Recipe Icon (Red mixing bowl with a spoon and yellow addition symbol.">
</a>
</div>
<form id="search-form" th:action="@{/explore}" method="get">
<input type="text" id="site-search" name="q" th:value="${q}" placeholder="Search recipes">
<button type="submit">Search</button>
<!-- Main Content --> <h3>Prep Time</h3>
<main class="main-content"> <label>
<div class="search-bar"> <input type="checkbox" name="prepTime" value="15"> &lt;15 min
<form action="/search" method="get"> </label><br>
<label for="site-search">Search:</label> <label>
<div class="search-btn"> <input type="checkbox" name="prepTime" value="30"> 15~30 min
<input type="search" id="site-search" name="q" placeholder="Search for recipes..."> </label><br>
<button type="submit"><i class="fa fa-search"></i></button> <label>
</div> <input type="checkbox" name="prepTime" value="60"> 30~60 min
</label><br>
<label>
<input type="checkbox" name="prepTime" value="240"> 1~4 hours
</label><br>
<label>
<input type="checkbox" name="prepTime" value="241"> 4+ hours
</label>
<h3>Cook Time</h3>
<label>
<input type="checkbox" name="cookTime" value="15"> &lt;15 min
</label><br>
<label>
<input type="checkbox" name="cookTime" value="30"> 15~30 min
</label><br>
<label>
<input type="checkbox" name="cookTime" value="60"> 30~60 min
</label><br>
<label>
<input type="checkbox" name="cookTime" value="120"> 1~2 hours
</label><br>
<label>
<input type="checkbox" name="cookTime" value="121"> 2+ hours
</label>
<h3>Price</h3>
<label>
<input type="checkbox" name="price" value="1"> $
</label><br>
<label>
<input type="checkbox" name="price" value="2"> $$
</label><br>
<label>
<input type="checkbox" name="price" value="3"> $$$
</label><br>
<label>
<input type="checkbox" name="price" value="4"> $$$$
</label>
</form> </form>
</div>
<div class="recipe-card">
<a th:href="@{/recipes/{id}(id=${recipe.id})}" class="card" th:each="recipe : ${recipes}"> <div th:if="${#lists.isEmpty(recipes)}">
<div class="card-text"> <p>No recipes found.</p>
<h2 th:text="${recipe.title}"></h2>
<p th:text="${recipe.description}"></p>
</div>
<div class="card-right">
<div th:each="img : ${recipe.images}">
<img th:src="${img.imageUrl}" alt="Recipe Image"/>
</div>
</div> </div>
<div th:unless="${#lists.isEmpty(recipes)}">
<div th:each="recipe : ${recipes}" style="margin-bottom: 20px; border: 1px solid #ccc; padding: 10px;">
<h3>
<a th:href="@{/recipes/{id}(id=${recipe.id})}" th:text="${recipe.title}">Recipe Title</a>
</h3>
<p>
<strong>Author:</strong>
<a th:href="@{/users/{id}(id=${recipe.userDto.id})}"
th:text="${recipe.userDto.displayName != null and !#strings.isEmpty(recipe.userDto.displayName) ? recipe.userDto.displayName : recipe.userDto.username}">
Author Name
</a> </a>
</div> </p>
</main>
<!--Filter --> <p th:text="${recipe.description}">Recipe description</p>
<div class="body-right">
<div class="sidebar-right"> <p>
<h1> FILTER </h1> <strong>Prep:</strong> <span th:text="${recipe.prepTimeMinutes}">0</span> min |
</div> <strong>Cook:</strong> <span th:text="${recipe.cookTimeMinutes}">0</span> min |
<strong>Servings:</strong> <span th:text="${recipe.servings}">0</span> |
<strong>Cost:</strong> <span th:text="${recipe.cost}">0</span>
</p>
<div th:if="${recipe.images != null and !#lists.isEmpty(recipe.images)}">
<img th:src="${recipe.images[0].imageUrl}" alt="Recipe Image" style="max-width: 200px;">
</div>
<p><a th:href="@{/recipes/{id}(id=${recipe.id})}">View Recipe</a></p>
</div> </div>
</div> </div>
<script>
(function () {
const input = document.getElementById('site-search');
const tags = [];
const params = new URLSearchParams(window.location.search);
params.getAll('prices').forEach(p => {
const cb = document.querySelector(`input[name="price"][value="${p}"]`);
if (cb) cb.checked = true;
});
params.getAll('cookTime').forEach(p => {
const cb = document.querySelector(`input[name="cookTime"][value="${p}"]`);
if (cb) cb.checked = true;
});
params.getAll('prepTime').forEach(p => {
const cb = document.querySelector(`input[name="prepTime"][value="${p}"]`);
if (cb) cb.checked = true;
});
params.getAll('tags').forEach(addChip);
input.addEventListener('keydown', (e) => {
if ((e.key === ' ' || e.key === 'Enter') && input.value.includes('#')) {
const val = input.value + ' ';
const match = val.match(/(^|\s)(#\w+)(\s)/);
if (match) {
e.preventDefault();
addChip(match[2].substring(1));
input.value = val.replace(match[0], '');
input.value += ' ';
}
}
});
document.querySelectorAll('input[name="price"]').forEach(cb => {
cb.addEventListener('change', submitSearch);
});
document.querySelectorAll('input[name="cookTime"]').forEach(cb => {
cb.addEventListener('change', submitSearch);
});
document.querySelectorAll('input[name="prepTime"]').forEach(cb => {
cb.addEventListener('change', submitSearch);
});
document.getElementById('search-form').addEventListener('submit', (e) => {
e.preventDefault();
submitSearch();
});
function addChip(tag) {
if (tags.includes(tag)) return;
tags.push(tag);
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = `#${tag} <button type="button" aria-label="Remove ${tag}">×</button>`;
chip.querySelector('button').addEventListener('click', () => {
chip.remove();
tags.splice(tags.indexOf(tag), 1);
submitSearch();
});
input.insertAdjacentElement('afterend', chip);
}
function submitSearch() {
const cleanedQuery = input.value.replace(/#\w+/g, '').trim();
const out = new URLSearchParams();
if (cleanedQuery) out.set('q', cleanedQuery);
tags.forEach(t => out.append('tags', t));
document.querySelectorAll('input[name="price"]:checked')
.forEach(cb => out.append('prices', cb.value));
document.querySelectorAll('input[name="cookTime"]:checked')
.forEach(cb => out.append('cookTime', cb.value));
document.querySelectorAll('input[name="prepTime"]:checked')
.forEach(cb => out.append('prepTime', cb.value));
window.location.href = '/explore?' + out.toString();
}
})();
</script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -21,7 +21,7 @@
<ul> <ul>
<li><a href="/">Home</a></li> <li><a href="/">Home</a></li>
<li><a th:href="@{/explore}">Explore</a></li> <li><a th:href="@{/explore}">Explore</a></li>
<li><a href="#">Profile</a></li> <li><a th:href="@{/my-profile}">Profile</a></li>
<li><a href="#">Saved</a></li> <li><a href="#">Saved</a></li>
<li> <li>
<form th:action="@{/logout}" method="post"> <form th:action="@{/logout}" method="post">
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>My Profile</title>
</head>
<body>
<h1>My Profile</h1>
<p><strong>Username:</strong> <span th:text="${profile.username}">username</span></p>
<p>
<strong>Public Profile:</strong>
<a th:href="@{/users/{id}(id=${profile.id})}">View my public profile</a>
</p>
<h2>Edit Profile</h2>
<form th:action="@{/my-profile/update}" method="post" th:object="${updateProfileDto}">
<div>
<label for="displayName">Screen Name</label><br>
<input type="text" id="displayName" th:field="*{displayName}" maxlength="100">
</div>
<br>
<div>
<label for="bio">Bio</label><br>
<textarea id="bio" th:field="*{bio}" rows="6" cols="50" maxlength="1000"></textarea>
</div>
<br>
<button type="submit">Save Profile</button>
</form>
<h2>My Recipes</h2>
<div th:if="${#lists.isEmpty(profile.recipes)}">
<p>You have not created any recipes yet.</p>
</div>
<div th:unless="${#lists.isEmpty(profile.recipes)}">
<div th:each="recipe : ${profile.recipes}" style="margin-bottom: 20px; border: 1px solid #ccc; padding: 10px;">
<h3 th:text="${recipe.title}">Recipe Title</h3>
<p>
<strong>Author:</strong>
<a th:href="@{/users/{id}(id=${recipe.userDto.id})}"
th:text="${recipe.userDto.effectiveDisplayName}">
Author Name
</a>
</p>
<p th:text="${recipe.description}">Recipe description</p>
<a th:href="@{/recipes/{id}(id=${recipe.id})}">View Recipe</a>
<span> | </span>
<a th:href="@{/recipes/{id}/edit(id=${recipe.id})}">Edit Recipe</a>
</div>
</div>
</body>
</html>
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Explore Recipes</title>
</head>
<body>
<h1>Explore Recipes</h1>
<nav>
<a th:href="@{/}">Home</a> |
<a th:href="@{/explore}">Explore</a> |
<a th:href="@{/my-profile}">Profile</a>
</nav>
<form id="search-form" th:action="@{/explore}" method="get">
<input type="text" id="site-search" name="q" th:value="${q}" placeholder="Search recipes">
<button type="submit">Search</button>
<h3>Prep Time</h3>
<label>
<input type="checkbox" name="prepTime" value="15"> &lt;15 min
</label><br>
<label>
<input type="checkbox" name="prepTime" value="30"> 15~30 min
</label><br>
<label>
<input type="checkbox" name="prepTime" value="60"> 30~60 min
</label><br>
<label>
<input type="checkbox" name="prepTime" value="240"> 1~4 hours
</label><br>
<label>
<input type="checkbox" name="prepTime" value="241"> 4+ hours
</label>
<h3>Cook Time</h3>
<label>
<input type="checkbox" name="cookTime" value="15"> &lt;15 min
</label><br>
<label>
<input type="checkbox" name="cookTime" value="30"> 15~30 min
</label><br>
<label>
<input type="checkbox" name="cookTime" value="60"> 30~60 min
</label><br>
<label>
<input type="checkbox" name="cookTime" value="120"> 1~2 hours
</label><br>
<label>
<input type="checkbox" name="cookTime" value="121"> 2+ hours
</label>
<h3>Price</h3>
<label>
<input type="checkbox" name="price" value="1"> $
</label><br>
<label>
<input type="checkbox" name="price" value="2"> $$
</label><br>
<label>
<input type="checkbox" name="price" value="3"> $$$
</label><br>
<label>
<input type="checkbox" name="price" value="4"> $$$$
</label>
</form>
<div th:if="${#lists.isEmpty(recipes)}">
<p>No recipes found.</p>
</div>
<div th:unless="${#lists.isEmpty(recipes)}">
<div th:each="recipe : ${recipes}" style="margin-bottom: 20px; border: 1px solid #ccc; padding: 10px;">
<h3>
<a th:href="@{/recipes/{id}(id=${recipe.id})}" th:text="${recipe.title}">Recipe Title</a>
</h3>
<p>
<strong>Author:</strong>
<a th:href="@{/users/{id}(id=${recipe.userDto.id})}"
th:text="${recipe.userDto.displayName != null and !#strings.isEmpty(recipe.userDto.displayName) ? recipe.userDto.displayName : recipe.userDto.username}">
Author Name
</a>
</p>
<p th:text="${recipe.description}">Recipe description</p>
<p>
<strong>Prep:</strong> <span th:text="${recipe.prepTimeMinutes}">0</span> min |
<strong>Cook:</strong> <span th:text="${recipe.cookTimeMinutes}">0</span> min |
<strong>Servings:</strong> <span th:text="${recipe.servings}">0</span> |
<strong>Cost:</strong> <span th:text="${recipe.cost}">0</span>
</p>
<div th:if="${recipe.images != null and !#lists.isEmpty(recipe.images)}">
<img th:src="${recipe.images[0].imageUrl}" alt="Recipe Image" style="max-width: 200px;">
</div>
<p><a th:href="@{/recipes/{id}(id=${recipe.id})}">View Recipe</a></p>
</div>
</div>
<script>
(function () {
const input = document.getElementById('site-search');
const tags = [];
const params = new URLSearchParams(window.location.search);
params.getAll('prices').forEach(p => {
const cb = document.querySelector(`input[name="price"][value="${p}"]`);
if (cb) cb.checked = true;
});
params.getAll('cookTime').forEach(p => {
const cb = document.querySelector(`input[name="cookTime"][value="${p}"]`);
if (cb) cb.checked = true;
});
params.getAll('prepTime').forEach(p => {
const cb = document.querySelector(`input[name="prepTime"][value="${p}"]`);
if (cb) cb.checked = true;
});
params.getAll('tags').forEach(addChip);
input.addEventListener('keydown', (e) => {
if ((e.key === ' ' || e.key === 'Enter') && input.value.includes('#')) {
const val = input.value + ' ';
const match = val.match(/(^|\s)(#\w+)(\s)/);
if (match) {
e.preventDefault();
addChip(match[2].substring(1));
input.value = val.replace(match[0], '');
input.value += ' ';
}
}
});
document.querySelectorAll('input[name="price"]').forEach(cb => {
cb.addEventListener('change', submitSearch);
});
document.querySelectorAll('input[name="cookTime"]').forEach(cb => {
cb.addEventListener('change', submitSearch);
});
document.querySelectorAll('input[name="prepTime"]').forEach(cb => {
cb.addEventListener('change', submitSearch);
});
document.getElementById('search-form').addEventListener('submit', (e) => {
e.preventDefault();
submitSearch();
});
function addChip(tag) {
if (tags.includes(tag)) return;
tags.push(tag);
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = `#${tag} <button type="button" aria-label="Remove ${tag}">×</button>`;
chip.querySelector('button').addEventListener('click', () => {
chip.remove();
tags.splice(tags.indexOf(tag), 1);
submitSearch();
});
input.insertAdjacentElement('afterend', chip);
}
function submitSearch() {
const cleanedQuery = input.value.replace(/#\w+/g, '').trim();
const out = new URLSearchParams();
if (cleanedQuery) out.set('q', cleanedQuery);
tags.forEach(t => out.append('tags', t));
document.querySelectorAll('input[name="price"]:checked')
.forEach(cb => out.append('prices', cb.value));
document.querySelectorAll('input[name="cookTime"]:checked')
.forEach(cb => out.append('cookTime', cb.value));
document.querySelectorAll('input[name="prepTime"]:checked')
.forEach(cb => out.append('prepTime', cb.value));
window.location.href = '/explore?' + out.toString();
}
})();
</script>
</body>
</html>