explore page in progress

This commit is contained in:
kaipher7
2026-04-16 11:22:18 -06:00
9 changed files with 200 additions and 110 deletions
@@ -0,0 +1,34 @@
package com.example.demo.controller;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalValidationHandler {
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Map<String, String>> handleConstraintViolation(ConstraintViolationException ex) {
Map<String, String> errors = new HashMap<>();
ex.getConstraintViolations().forEach(violation -> {
String fieldPath = violation.getPropertyPath().toString();
errors.put(fieldPath, violation.getMessage());
});
return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error -> {
errors.put(error.getField(), error.getDefaultMessage());
});
return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
}
}
@@ -1,6 +1,8 @@
package com.example.demo.controller;
import java.security.Principal;
import java.util.List;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -13,20 +15,26 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import com.example.demo.dto.UserDto;
import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import com.example.demo.repository.UserRepo;
@RestController
@RequestMapping("/api/users")
public class UserController {
private UserService userService;
private UserRepo userRepo;
public UserController(UserService userService) {
public UserController(UserService userService, UserRepo userRepo) {
super();
this.userService = userService;
this.userRepo = userRepo;
}
// build create user REST API
@@ -49,6 +57,16 @@ public class UserController {
return new ResponseEntity<>(users, HttpStatus.OK);
}
// build get current user REST API
@GetMapping("/me")
public UserDto getLoggedInUser(Principal principal) {
if (principal == null) return null;
String username = principal.getName();
User user = (userRepo.findByUsername(username))
.orElse(null);
return userService.convertToDto(user);
}
// build get user by id REST API
// http://localhost:8080/api/users/(id number goes here)
@@ -4,6 +4,8 @@ import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.GeneratedValue;
@@ -23,7 +25,9 @@ public class Ingredient {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, unique = true)
@Column(nullable = false, unique = true,columnDefinition = "TEXT")
@NotBlank(message = "Please provide an ingredient name")
@Size(max = 128, message = "Name cannot be longer than 128 characters")
private String name;
@OneToMany(mappedBy = "ingredient")
@@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
@@ -20,17 +21,20 @@ public class Recipe {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(columnDefinition = "TEXT")
@NotBlank(message = "Please provide a recipe title")
@Size(max = 128, message = "Title cannot be longer than 128 characters")
private String title;
@Column(columnDefinition = "TEXT")
@Size(max = 500, message = "Description cannot be longer than 500 characters")
private String description;
@NotNull(message = "Please Provide a prep time amount in mintutes")
@NotNull(message = "Please Provide a prep time amount in minutes")
@Positive(message = "This value cannot be negative")
private Integer prepTimeMinutes;
@NotNull(message = "Please Provide a cook time amount in mintutes")
@NotNull(message = "Please Provide a cook time amount in minutes")
@Positive(message = "This value cannot be negative")
private Integer cookTimeMinutes;
@@ -52,10 +56,13 @@ public class Recipe {
// Recipe ingredients relationship
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@NotEmpty(message = "At least one ingredient is required")
@Size(max = 256, message = "Cannot have more than 256 ingredients")
private Set<RecipeIngredient> recipeIngredients = new HashSet<>();
// Recipe Steps relationship
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@NotEmpty(message = "At least one step is required")
@Size(max = 50, message = "Cannot have more than 50 steps")
private Set<Step> steps = new HashSet<>();
// Recipe Images relationship
@@ -1,6 +1,10 @@
package com.example.demo.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import lombok.EqualsAndHashCode;
@@ -24,10 +28,16 @@ public class RecipeIngredient {
@EqualsAndHashCode.Include
private Ingredient ingredient;
@NotNull(message = "Please Provide a Quantity")
@Positive(message = "Quantity cannot be negative")
private BigDecimal quantity;
@Column(columnDefinition = "TEXT")
@Size(max = 32, message = "Unit cannot be longer than 32 characters")
private String unit;
@Column(columnDefinition = "TEXT")
@Size(max = 128, message = "Note cannot be longer than 128 characters")
private String notes;
public RecipeIngredient() {
@@ -1,6 +1,7 @@
package com.example.demo.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Size;
import lombok.EqualsAndHashCode;
@Entity
@@ -15,6 +16,7 @@ public class Step {
private Integer stepNumber;
@Column(name = "instruction", nullable = false, columnDefinition = "TEXT")
@Size(max = 500, message = "Instruction cannot be longer than 500 characters")
private String instruction;
@ManyToOne(fetch = FetchType.LAZY)
@@ -16,6 +16,8 @@ import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -53,6 +55,7 @@ public class User implements UserDetails {
// Favorite relationship and also junction table
@ManyToMany(fetch = FetchType.LAZY)
@JsonIgnore
@JoinTable(name = "favorites", joinColumns = { @JoinColumn(name = "userId") }, inverseJoinColumns = {
@JoinColumn(name = "recipeId") })
private Set<Recipe> FavRecipes = new HashSet<>();
@@ -1,101 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Create Thyme Crunch Account</title>
<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">
</head>
<body>
<header class="top-header">
<img th:src="@{images/header_left.png}" alt="Violin f-hole shape to the left of header." class="swirl">
<h1 class="site-name">Thyme Crunch</h1>
<img th:src="@{images/header_right.png}" alt="Violin f-hole shape to the right of header." class="swirl">
</header>
<!-- Main Content -->
<main class="main-content">
<div class="login-box">
<h2>Create Account</h2>
<form id="createUserForm">
<div class="rows">
<label for="username">Username</label>
<input type="text" id="username" required>
</div>
<div class="rows">
<label for="email">Email</label>
<input type="email" id="email" required>
</div>
<div class="rows">
<label for="password">Password</label>
<input type="password" id="password" required>
</div>
<div class="rows">
<label for="confirmPassword">Confirm Password</label>
<input type="password" id="confirmPassword" required>
</div>
<p id="passwordError"></p>
<button type="submit">Create</button>
</form>
</div>
</main>
</body>
</html>
<script>
document.addEventListener("DOMContentLoaded", function () {
const passwordField = document.getElementById("password");
const confirmPasswordField = document.getElementById("confirmPassword");
function checkPasswords() {
if (confirmPasswordField.value === "") {
confirmPasswordField.classList.remove("invalid");
return;
}
if (passwordField.value !== confirmPasswordField.value) {
confirmPasswordField.classList.add("invalid");
} else {
confirmPasswordField.classList.remove("invalid");
}
}
passwordField.addEventListener("input", checkPasswords);
confirmPasswordField.addEventListener("input", checkPasswords);
document.getElementById("createUserForm").addEventListener("submit", function(e) {
const password = passwordField.value;
const confirmPassword = confirmPasswordField.value;
if (password !== confirmPassword) {
e.preventDefault();
confirmPasswordField.classList.add("invalid");
return;
}
const userData = {
username: document.getElementById("username").value,
email: document.getElementById("email").value,
hashedpassword: password,
role: "USER"
};
fetch("http://localhost:8080/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(userData)
});
e.preventDefault();
});
});
</script>
@@ -2,6 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
<meta name="_csrf" th:content="${_csrf.token}"/>
<title>Create Thyme Crunch Recipe</title>
<link rel="stylesheet" th:href="@{css/create-recipe.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">
@@ -226,6 +228,117 @@ function renderTags() {
btn.addEventListener('click', () => removeTag(btn.dataset.tag));
});
}
async function getLoggedInUser() {
try {
const res = await fetch('http://localhost:8080/api/users/me', {
credentials: 'include'
});
if (!res.ok) throw new Error('Failed to get logged-in user');
return await res.json();
} catch (err) {
console.error('Error fetching user:', err);
return null;
}
}
function buildRecipeJSON(user) {
const title = document.getElementById('title').value.trim();
const description = document.getElementById('desc').value.trim();
const prepTimeMinutes = Number(document.getElementById('prep').value);
const cookTimeMinutes = Number(document.getElementById('cooking').value);
const servings = Number(document.getElementById('servings').value);
const status = "DRAFT";
// Ingredients
const recipeIngredients = [...document.querySelectorAll('#ingredients-container .dynamic-row')]
.map(row => {
const qtyValue = Number(row.querySelector('.ing-qty').value.trim());
return {
ingredient: { name: row.querySelector('.ing-name').value.trim() },
quantity: qtyValue,
unit: row.querySelector('.ing-unit').value.trim(),
notes: row.querySelector('.ing-notes').value.trim()
};
})
.filter(item => item.ingredient.name);
// Steps
const steps = [...document.querySelectorAll('#steps-container textarea')]
.map((el, i) => ({ stepNumber: i + 1, instruction: el.value.trim() }))
.filter(item => item.instruction);
// Images
// Tags
const tagsInput = tags;
const tagsArray = tagsInput.map(t => ({ name: t }));
return {
title,
description,
prepTimeMinutes,
cookTimeMinutes,
servings,
status,
user,
recipeIngredients,
steps,
//images,
tags: tagsArray
};
}
document.getElementById('publish-btn').addEventListener('click', async function(e) {
e.preventDefault();
const user = await getLoggedInUser();
if (!user) {
alert("Unable to fetch logged-in user. Please refresh and try again.");
return;
}
console.log("Logged-in user:", user);
const recipeJSON = buildRecipeJSON(user);
console.log("Recipe JSON to submit:", JSON.stringify(recipeJSON, null, 2));
try {
const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
const res = await fetch('http://localhost:8080/api/recipes', {
method: 'POST',
headers: {
[csrfHeader]: csrfToken,
'Content-Type': 'application/json'
},
body: JSON.stringify(recipeJSON),
credentials: 'include'
});
if (res.ok) {
const data = await res.json();
console.log("Recipe created:", data);
alert("Recipe created successfully!");
} else {
const errorData = await res.json();
console.error("Validation errors:", errorData);
const firstError = Object.values(errorData)[0];
alert(firstError);
}
} catch (err) {
console.error("Network error:", err);
alert("Network error. Check console for details.");
}
});
</script>
</body>
</html>