diff --git a/.env b/.env index 3ed23bb..2737d5b 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -REACT_APP_API_URL = 'http://127.0.0.1:8000' +REACT_APP_API_URL = 'https://kasula-develop-5q5vehm3ja-ew.a.run.app' # LA VARIABLE ESTÀ PENSADA PERQUÈ NO PORTI BARRA AL FINAL diff --git a/README.md b/README.md index 8bf8b0d..b768e3b 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,4 @@ npm install -g serve npm run build serve -s build ``` + diff --git a/package-lock.json b/package-lock.json index 4c5325a..4b25514 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.5.1", + "@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/react-fontawesome": "^0.2.0", "@testing-library/jest-dom": "^5.17.0", @@ -23,6 +24,7 @@ "react-bootstrap": "^2.9.1", "react-bootstrap-icons": "^1.10.3", "react-dom": "^18.2.0", + "react-icons": "^4.12.0", "react-router-dom": "^6.17.0", "react-scripts": "5.0.1", "react-toastify": "^9.1.3", @@ -2562,6 +2564,27 @@ "node": ">=6" } }, + "node_modules/@fortawesome/free-regular-svg-icons": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.5.1.tgz", + "integrity": "sha512-m6ShXn+wvqEU69wSP84coxLbNl7sGVZb+Ca+XZq6k30SzuP3X4TfPqtycgUh9ASwlNh5OfQCd8pDIWxl+O+LlQ==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.5.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-regular-svg-icons/node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz", + "integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==", + "hasInstallScript": true, + "engines": { + "node": ">=6" + } + }, "node_modules/@fortawesome/free-solid-svg-icons": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.2.tgz", @@ -15610,6 +15633,14 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" }, + "node_modules/react-icons": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz", + "integrity": "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -20446,6 +20477,21 @@ } } }, + "@fortawesome/free-regular-svg-icons": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.5.1.tgz", + "integrity": "sha512-m6ShXn+wvqEU69wSP84coxLbNl7sGVZb+Ca+XZq6k30SzuP3X4TfPqtycgUh9ASwlNh5OfQCd8pDIWxl+O+LlQ==", + "requires": { + "@fortawesome/fontawesome-common-types": "6.5.1" + }, + "dependencies": { + "@fortawesome/fontawesome-common-types": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz", + "integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==" + } + } + }, "@fortawesome/free-solid-svg-icons": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.2.tgz", @@ -29977,6 +30023,12 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" }, + "react-icons": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz", + "integrity": "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==", + "requires": {} + }, "react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", diff --git a/package.json b/package.json index 0978356..1a94fac 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.5.1", + "@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/react-fontawesome": "^0.2.0", "@testing-library/jest-dom": "^5.17.0", @@ -18,6 +19,7 @@ "react-bootstrap": "^2.9.1", "react-bootstrap-icons": "^1.10.3", "react-dom": "^18.2.0", + "react-icons": "^4.12.0", "react-router-dom": "^6.17.0", "react-scripts": "5.0.1", "react-toastify": "^9.1.3", diff --git a/src/assets/jsonData/sort_options.json b/src/assets/jsonData/sort_options.json new file mode 100644 index 0000000..a88e6c0 --- /dev/null +++ b/src/assets/jsonData/sort_options.json @@ -0,0 +1,28 @@ +{"fields": [{ + "name": "none", + "displayName": "---" +}, { + "name": "name", + "displayName": "Title" +}, { + "name": "average_rating", + "displayName": "Rating" +}, { + "name": "cooking_time", + "displayName": "Cooking Time" +}, { + "name": "difficulty", + "displayName": "Difficulty" +}, { + "name": "energy", + "displayName": "Energy" +}, { + "name": "username", + "displayName": "Recipe Author" +}, { + "name": "creation_date", + "displayName": "Creation Date" +}, { + "name": "updated_at", + "displayName": "Last Modified" +}]} \ No newline at end of file diff --git a/src/components/AuthContext.jsx b/src/components/AuthContext.jsx index 5ca9be0..6fdfc6e 100644 --- a/src/components/AuthContext.jsx +++ b/src/components/AuthContext.jsx @@ -8,6 +8,7 @@ export const AuthProvider = ({ children }) => { const logout = () => { setToken(null); localStorage.removeItem("token"); + localStorage.removeItem("currentUser"); }; const isLogged = () => { diff --git a/src/components/KasulaNavbar.jsx b/src/components/KasulaNavbar.jsx index d15e2c8..805879f 100644 --- a/src/components/KasulaNavbar.jsx +++ b/src/components/KasulaNavbar.jsx @@ -1,6 +1,6 @@ //React import React, { useState, useEffect } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { useAuth } from "./AuthContext"; import "../css/common.css"; @@ -28,6 +28,7 @@ import chef from "../assets/chef.png"; function KasulaNavbar() { const { token, logout, isLogged } = useAuth(); + const navigate = useNavigate(); const [user, setUser] = useState({}); const [showModal, setShowModal] = useState(false); const [showPostRecipe, setShowPostRecipe] = useState(false); @@ -69,13 +70,13 @@ function KasulaNavbar() { const handleClosePostRecipeSuccessfulModal = () => { setShowPostRecipe(false); - //window.location.reload(); }; const handleLogout = () => { localStorage.setItem("logged", "false"); // This will update the localStorage handleCloseModal(); // This will close the modal logout(); // This will remove the token from the localStorage + window.location.reload(); }; const CustomToggle = React.forwardRef(({ onClick }, ref) => ( @@ -138,6 +139,13 @@ function KasulaNavbar() { + Profile + + + { handleOpenModal(); }} diff --git a/src/components/LikesReview.jsx b/src/components/LikesReview.jsx index 71c56f9..74c3a32 100644 --- a/src/components/LikesReview.jsx +++ b/src/components/LikesReview.jsx @@ -2,10 +2,10 @@ import React, { useState } from "react"; import {Row, Col } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faThumbsUp, faHeart } from "@fortawesome/free-solid-svg-icons"; +import { faHeart as heartSolid } from '@fortawesome/free-solid-svg-icons'; // Icono sólido +import { faHeart as heartRegular } from '@fortawesome/free-regular-svg-icons'; // Icono regular import { useAuth } from "./AuthContext"; - function LikesReview({ reviewUsername, recipeId, reviewId, initialLikes, likedBy, reloadReviews }) { const [likes, setLikes] = useState(initialLikes); const [hasLiked, setHasLiked] = useState(false); @@ -43,7 +43,6 @@ function LikesReview({ reviewUsername, recipeId, reviewId, initialLikes, likedBy setLikes(likes + 1); setHasLiked(true); setHasLikedByUser(true); - // Realiza la lógica para enviar el like a la base de datos try { const response = await fetch( `${process.env.REACT_APP_API_URL}/review/like/${recipeId}/${reviewId}`, @@ -68,13 +67,15 @@ function LikesReview({ reviewUsername, recipeId, reviewId, initialLikes, likedBy const cursorStyle = isLogged === 'true' && !isOwnerReview ? { cursor: "pointer" } : { cursor: "not-allowed" }; + const iconoLike = hasLikedByUser ? heartSolid : heartRegular; + return ( @@ -82,7 +83,7 @@ function LikesReview({ reviewUsername, recipeId, reviewId, initialLikes, likedBy - ); + ); } export default LikesReview; diff --git a/src/components/ModifyReview.jsx b/src/components/ModifyReview.jsx new file mode 100644 index 0000000..8866fcb --- /dev/null +++ b/src/components/ModifyReview.jsx @@ -0,0 +1,162 @@ +// ModifyReview.js +import React, { useState } from 'react'; +import { Button, Modal, Form } from 'react-bootstrap'; +import { useAuth } from "./AuthContext"; +import { + StarFill, + Star +} from "react-bootstrap-icons"; + +const ModifyReview = ({ show, reviewId, recipeId, onHide, reloadReviews, funct, reviewInfo }) => { + const [newComment, setNewComment] = useState(reviewInfo.comment); + const [newRating, setNewRating] = useState(reviewInfo.rating); + const [newImage, setNewImage] = useState(reviewInfo.file); + const [characterCount, setCharacterCount] = useState(0); + const { token } = useAuth(); + + const handleUpdateReview = async () => { + const bodyData = { + comment: newComment, + rating: newRating, + }; + if (newImage) { + bodyData.file = newImage; + } + console.log(">>>>NewImage: ", newImage) + + try { + const response = await fetch( + `${process.env.REACT_APP_API_URL}/review/${recipeId}/${reviewId}`, + { + method: 'PUT', + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ comment: newComment, rating: newRating }), + file: newImage + } + ); + + const data = await response.json(); + if (response.ok) { + console.log('Review actualizada:', data); + reloadReviews(); + } else { + console.error('Error al actualizar la revisión:', data); + } + } catch (error) { + console.error('Error en la solicitud de actualización de la revisión:', error); + } + + onHide(); + }; + + const handleDeleteReview = async () => { + try { + const response = await fetch( + `${process.env.REACT_APP_API_URL}/review/${recipeId}/${reviewId}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + const data = await response.json(); + if (response.ok) { + console.log('Review eliminada:', data); + reloadReviews(); + } else { + console.error('Error al eliminar la revisión:', data); + } + } catch (error) { + console.error('Error en la solicitud de eliminación de la revisión:', error); + } + + onHide(); + }; + + const renderStars = (amount) => { + let stars = []; + for (let i = 1; i <= 5; i++) { + stars.push( + setNewRating(i)} + > + {i <= amount ? : } + + ); + } + return stars; + }; + + const handleReviewChange = (e) => { + const inputReview = e.target.value; + setNewComment(inputReview) + setCharacterCount(inputReview.length); + }; + + const handleConfirmDelete = () => { + handleDeleteReview(); + }; + + return ( + + + {funct === 'Edit' ? 'Modify' : 'Delete'} review + + + {funct === 'Edit' ? ( +
+ + New Review + 120 ? 'red' : null }} + /> + {characterCount > 120 && ( +
You exceeded 120 characters.
+ )} +
Num characters: {characterCount}
+
+ + New Rating +
{renderStars(newRating)}
+
+
+ ) : ( +

Are you sure delete review?

+ )} +
+ + {funct === 'Edit' ? ( + + ) : ( + <> + + + + )} + +
+ ); +}; + +export default ModifyReview; diff --git a/src/components/PostRecipe.jsx b/src/components/PostRecipe.jsx index ba64552..bba13b9 100644 --- a/src/components/PostRecipe.jsx +++ b/src/components/PostRecipe.jsx @@ -28,7 +28,7 @@ import { Table, } from "react-bootstrap"; -const RecipePost = ({ onClose }) => { +const RecipePost = ({onClose, edit, id}) => { const { token } = useAuth(); const Unit = { @@ -76,6 +76,42 @@ const RecipePost = ({ onClose }) => { } }, [localStorage.getItem("logged"), navigate]); + useEffect(() => { + if(edit){ + fetchRecipeData(); + } + }, []); + + const fetchRecipeData = async () => { + try { + const response = await fetch(`${process.env.REACT_APP_API_URL}/recipe/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Error fetching recipe data'); + } + + const data = await response.json(); + + setRecipeName(data.name); + setIngredients(data.ingredients); + setPreparation(data.instructions); + setTime(data.cooking_time); + setEnergy(data.energy); + setDifficulty(data.difficulty); + setImagePreviewUrl(data.main_image) + + } catch (error) { + console.error('Error:', error); + } + }; + + const renderStars = (amount) => { let stars = []; for (let i = 1; i <= 5; i++) { @@ -228,34 +264,61 @@ const RecipePost = ({ onClose }) => { formData.append("recipe", JSON.stringify(recipeData)); if (image) { formData.append("files", image); - console.log(formData); + } - - - try { - const response = await fetch(process.env.REACT_APP_API_URL + "/recipe/", { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, - body: formData, - }); - - const data = await response.json(); - - if (response.ok) { - setSubmitMessage("Recipe posted successfully", data); - setPostRecipeSuccess(true); - //onClose(); - } else { - setSubmitMessage("Error posting recipe: " + JSON.stringify(data)); + console.log(formData["recipe"]); + if(edit){ + try { + const response = await fetch(process.env.REACT_APP_API_URL + "/recipe/" + id, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + const data = await response.json(); + + if (response.ok) { + setSubmitMessage("Updated correctly!", data); + setPostRecipeSuccess(true); + //onClose(); + } else { + setSubmitMessage("Error updating recipe: " + JSON.stringify(data)); + } + } catch (error) { + setSubmitMessage(JSON.stringify(error)); + setPostRecipeSuccess(false); + } finally { + setShowPostRecipeConfirmation(true); + } + }else{ + try { + const response = await fetch(process.env.REACT_APP_API_URL + "/recipe/", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + const data = await response.json(); + + if (response.ok) { + setSubmitMessage("Posted correctly!", data); + setPostRecipeSuccess(true); + //onClose(); + } else { + setSubmitMessage("Error posting recipe: " + JSON.stringify(data)); + } + } catch (error) { + setSubmitMessage(JSON.stringify(error)); + setPostRecipeSuccess(false); + } finally { + setShowPostRecipeConfirmation(true); } - } catch (error) { - setSubmitMessage(JSON.stringify(error)); - setPostRecipeSuccess(false); - } finally { - setShowPostRecipeConfirmation(true); } + }; const handleImageUpload = (event) => { @@ -268,7 +331,7 @@ const RecipePost = ({ onClose }) => { const handlePostRecipeConfirmationClose = () => { setShowPostRecipeConfirmation(false); if (postSuccess) { - navigate("/"); + onClose(); } }; @@ -661,7 +724,7 @@ const RecipePost = ({ onClose }) => { variant={postSuccess ? "success" : "secondary"} onClick={handlePostRecipeConfirmationClose} > - {postSuccess ? "Go to recipes" : "Close"} + {postSuccess ? "Okay" : "Close"} diff --git a/src/components/PostReview.jsx b/src/components/PostReview.jsx index bd7d92a..b221400 100644 --- a/src/components/PostReview.jsx +++ b/src/components/PostReview.jsx @@ -5,9 +5,6 @@ import { StarFill, Star } from "react-bootstrap-icons"; - - - const PostReview = ({ id, show, onHide, reloadReviews }) => { const [username, setUsername] = useState(''); const [review, setReview] = useState(''); @@ -17,7 +14,7 @@ const PostReview = ({ id, show, onHide, reloadReviews }) => { const { token } = useAuth(); const [error, setError] = useState(null); const [showErrorModal, setShowErrorModal] = useState(false); - + const [characterCount, setCharacterCount] = useState(0); useEffect(() => { fetch(process.env.REACT_APP_API_URL + "/user/me", { @@ -39,6 +36,13 @@ const PostReview = ({ id, show, onHide, reloadReviews }) => { setImage(selectedImage); }; + const handleReviewChange = (e) => { + const inputReview = e.target.value; + const remainingCharacters = 120 - inputReview.length; + setReview(inputReview); + setCharacterCount(remainingCharacters); + }; + const handlePostReview = async () => { const reviewData = { username: username, @@ -60,17 +64,15 @@ const PostReview = ({ id, show, onHide, reloadReviews }) => { }, body: formData, }); - // 403 owner - // 400 ya review const data = await response.json(); if (response.ok) { console.log(">>>Post hecho: ", data) } else{ - setError(data.detail); + setError("You have already reviewed this recipe!") + // setError(data.detail); setShowErrorModal(true); } } catch (error) { - // alert("HA FALLADO EL POST") setError(error); setShowErrorModal(true); } @@ -103,53 +105,64 @@ const PostReview = ({ id, show, onHide, reloadReviews }) => { return (<> - + Post a review - +
- + Review setReview(e.target.value)} + onChange={handleReviewChange} + style={{ borderColor: characterCount < 0 ? 'red' : null }} /> - - + {characterCount < 0 && ( +
You exceeded the character limit.
+ )} + +
Characters remaining: {characterCount}
+
+ Rating
{renderStars(difficulty)}
- + Select Image
- + -
setShowErrorModal(false)}> - - Error + + Not allowed - - + + {error &&

{error}

}
- - diff --git a/src/components/PrivacySettings.jsx b/src/components/PrivacySettings.jsx new file mode 100644 index 0000000..a062778 --- /dev/null +++ b/src/components/PrivacySettings.jsx @@ -0,0 +1,116 @@ +import React, { useState, useEffect } from "react"; +import { Tooltip, OverlayTrigger, Container, Row, Col, Dropdown, Form, Button } from "react-bootstrap"; +import { InfoCircleFill } from "react-bootstrap-icons"; + +const PrivacySettings = ({ + onClose, + handleSaveProfile, + handleVisibilityChange, + imPrivate, + usernameValidationMessage, + emailValidationMessage, + userNameAux, + userMailAux, + userBioAux, + usernameValid, + emailValid, + usernameValidated, + emailValidated, + onUsernameChange, + onEmailChange, + setUserBioAux +}) => { + + const renderTooltip = (message) => ( + {message} + ); + + useEffect(() => { + console.error(usernameValid==true) + }, [usernameValid]); + + return ( + + + +
+ {/* Sección de edición de perfil */} + + Username + onUsernameChange(e)} + isInvalid={usernameValidated && !usernameValid} + /> + + {usernameValidationMessage} + + + + Email + onEmailChange(e)} + isInvalid={emailValidated && !emailValid} + /> + + {emailValidationMessage} + + + + Biography + setUserBioAux(e.target.value)} + /> + + {/* Botón para guardar cambios */} + +
+ +
+ + +
Profile Visibility:
+ + + + + {imPrivate ? "Private" : "Public"} + + + + handleVisibilityChange(false)}> + + Public + + + + handleVisibilityChange(true)}> + + Private + + + + + +
+ +
+
+ ); +}; + +export default PrivacySettings; diff --git a/src/components/RecipeBrowser.jsx b/src/components/RecipeBrowser.jsx new file mode 100644 index 0000000..9b9a6ca --- /dev/null +++ b/src/components/RecipeBrowser.jsx @@ -0,0 +1,222 @@ +import { useState } from "react"; +import { + Container, + Stack, + InputGroup, + Form, + Offcanvas, + Badge, +} from "react-bootstrap"; +import { Funnel, FunnelFill, X, SortUp, SortDown } from "react-bootstrap-icons"; + +import RecipeFilters from "./RecipeFilters"; +import sort_options from "../assets/jsonData/sort_options.json"; +import "../css/common.css"; + +function RecipeBrowser({ onSearch }) { + const keyParsing = { + sortBy: "Sort by:", + sortAscending: "", + maxDifficulty: "Difficulty:", + maxTime: "Time:", + minRating: "Rating:", + maxCalories: "Calories", + }; + + const valueParsing = { + sortBy: "", + sortAscending: "", + maxDifficulty: " stars or less", + maxTime: " min or less", + minRating: " stars or more", + maxCalories: " cal or less", + }; + + const defaultFilters = { + sortBy: "none", + sortAscending: false, + maxDifficulty: 5, + maxTime: 180, + minRating: 0, + maxCalories: 5000, + }; + + const [recipeName, setRecipeName] = useState(""); + const [filtersOffCanvas, setFiltersOffCanvas] = useState({ + title: "Filters", + show: false, + values: JSON.parse(localStorage.getItem("filters")) || defaultFilters, + }); + + const handleCloseOffcanvas = ( + setOffCanvasState, + offCanvasState, + applyFilters, + filterValues + ) => { + setOffCanvasState({ + ...offCanvasState, + show: false, + values: filterValues || offCanvasState.values, + }); + if (applyFilters) { + onSearch(filterValues || offCanvasState.values, recipeName); + } + }; + + return ( + + + { + setFiltersOffCanvas({ ...filtersOffCanvas, show: true }); + }} + > + + + + { + setRecipeName(e.target.value); + onSearch(filtersOffCanvas.values, e.target.value); + }} + /> + + {recipeName.length > 0 && ( + { + setRecipeName(""); + onSearch(filtersOffCanvas.values, ""); + }} + /> + )} + + + + + {JSON.stringify(filtersOffCanvas.values) !== + JSON.stringify(defaultFilters) && ( +
{ + setFiltersOffCanvas({ + ...filtersOffCanvas, + values: defaultFilters, + }); + localStorage.setItem("filters", JSON.stringify(defaultFilters)); + onSearch(defaultFilters, recipeName); + }} + > + + Clear filters + +
+ )} + + {Object.entries(filtersOffCanvas.values) + .filter( + ([key, value]) => + value !== defaultFilters[key] || + (key === "sortAscending" && + filtersOffCanvas.values.sortBy !== "none") + ) + .map(([key, value]) => ( + + {keyParsing[key]}{" "} + {key === "sortBy" + ? sort_options.fields.filter( + (option) => option.name === value + )[0].displayName + : value === true + ? "Ascending" + : value === false + ? "Descending" + : value.toString().concat(valueParsing[key])} + {key === "sortAscending" ? ( +
{ + const newValues = { ...filtersOffCanvas.values }; + newValues[key] = !newValues[key]; + setFiltersOffCanvas({ + ...filtersOffCanvas, + values: newValues, + }); + localStorage.setItem( + "filters", + JSON.stringify(newValues) + ); + onSearch(newValues, recipeName); + }} + > + {value ? ( + + ) : ( + + )} +
+ ) : ( + { + const newValues = { ...filtersOffCanvas.values }; + newValues[key] = defaultFilters[key]; + setFiltersOffCanvas({ + ...filtersOffCanvas, + values: newValues, + }); + localStorage.setItem( + "filters", + JSON.stringify(newValues) + ); + onSearch(newValues, recipeName); + }} + /> + )} +
+ ))} +
+
+ + handleCloseOffcanvas(setFiltersOffCanvas, filtersOffCanvas, false) + } + > + + + {filtersOffCanvas.title} + + + + { + localStorage.setItem("filters", JSON.stringify(filters)); + handleCloseOffcanvas( + setFiltersOffCanvas, + filtersOffCanvas, + true, + filters + ); + }} + inValues={filtersOffCanvas.values} + /> + + +
+ ); +} + +export default RecipeBrowser; diff --git a/src/components/RecipeDetail.jsx b/src/components/RecipeDetail.jsx index b19f4a1..fe7fcd3 100644 --- a/src/components/RecipeDetail.jsx +++ b/src/components/RecipeDetail.jsx @@ -2,12 +2,15 @@ import React, { useState, useEffect } from "react"; import { useAuth } from "./AuthContext"; import "../css/common.css"; import "../css/Transitions.css"; +import RecipeCard from "./RecipeCard"; +import SimilarRecipes from "./SimilarRecipes"; import chefIcon from "../assets/icons/chef.png" -import { useParams, useNavigate } from "react-router-dom"; +import { useParams, useNavigate, Link } from "react-router-dom"; import defaultProfile from "../assets/defaultProfile.png"; import { CSSTransition } from "react-transition-group"; import gyozas from '../assets/gyozas.jpg'; import "bootstrap/dist/css/bootstrap.min.css"; +import { FaWhatsapp, FaLinkedin, FaTwitter, FaCopy } from 'react-icons/fa'; import { Container, Row, @@ -28,6 +31,7 @@ import { FolderSymlinkFill, Heart, HeartFill, + Share } from "react-bootstrap-icons"; import ImageModal from "./ImageModal"; import Reviews from "./Reviews"; @@ -36,16 +40,23 @@ import AddRecipeToCollection from "./AddRecipeToCollection"; function RecipeDetail() { const { token, isLogged } = useAuth(); - const [username, setUsername] = useState(localStorage.getItem("currentUser")); + const [myUserId, setMyUserId] = useState(''); + const [myUserName, setMyUserName] = useState(localStorage.getItem("currentUser")); const [userId, setUserId] = useState(''); const [userName, setUserName] = useState(''); const [userImage, setUserImage] = useState(''); - const [showModal, setShowModal] = useState(false); - const [user, setUser] = useState({}); + const [similarRecipes, setSimilarRecipes] = useState(''); const { id } = useParams(); const [recipe, setRecipe] = useState({ images: [] }); const [showReviews, setShowReviews] = useState(false); const [isLiked, setIsLiked] = useState(false); + const [adminMode, setadminMode] = useState(false); + const [showUnfollowModal, setShowUnfollowModal] = useState(false); + const [showLoginRedirectModal, setShowLoginRedirectModal] = useState(false); + const [confirmationMessage, setConfirmationMessage] = useState(""); + const [showConfirmation, setShowConfirmation] = useState(false); + const [isFollowing, setIsFollowing] = useState(null); + const [userFollowers, setUserFollowers] = useState([]); const [imageModal, setImageModal] = useState({ show: false, @@ -73,11 +84,20 @@ function RecipeDetail() { useEffect(() => { getRecipe(); + getSimilarRecipes(); if (isLogged()) { - getIsLiked(username); + getIsLiked(userName); } }, [id, reloadReviews]); + useEffect(() => { + if(myUserName == userName){ + setadminMode(true); + }else{ + setIsFollowing(userFollowers.includes(myUserName)); + } + }, [userName, userFollowers]); + const getRecipe = () => { fetch(process.env.REACT_APP_API_URL + `/recipe/${id}`) .then((response) => response.json()) @@ -89,6 +109,17 @@ function RecipeDetail() { .catch((error) => console.error("Error al obtener receta:", error)); }; + const getSimilarRecipes = () => { + fetch(process.env.REACT_APP_API_URL + `/recipe/similar/${id}`) + .then((response) => response.json()) + .then((data) => { + setSimilarRecipes(data) + console.log(">> console.error("Error al obtener recetas similares:", error)); + }; + + const fetchUserData = async (userId) => { try { const response = await fetch(process.env.REACT_APP_API_URL + '/user/' + userId, { @@ -106,12 +137,16 @@ function RecipeDetail() { setUserId(data.user_id); setUserName(data.username); setUserImage(data.profile_picture || ''); + setUserFollowers(data.followers || []); } catch (error) { console.error('Error fetching user data:', error); } }; - const handleNavigate = (userId) => { + const handleNavigate = (event, userId) => { + if (event) { + event.stopPropagation(); + } navigate(`/UserProfile/${userId}`); }; @@ -207,6 +242,96 @@ function RecipeDetail() { )); + const handleFollow = async () => { + if (!isLogged()) { + setShowLoginRedirectModal(true); + return; + } + + try { + const response = await fetch(process.env.REACT_APP_API_URL + `/user/follow/${userName}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + }); + + if (response.ok) { + setConfirmationMessage(`You're now following ${userName}.`); + setIsFollowing(true); + setToastData({ + message: `You're now following ${userName}.`, + variant: 'success', + show: true, + }); + } else { + throw new Error('There was an error following the user.'); + } + } catch (error) { + console.error('Error following user:', error); + setConfirmationMessage(error.toString()); + setShowConfirmation(true); + } + }; + + const handleUnfollow = async () => { + try { + const response = await fetch(process.env.REACT_APP_API_URL + `/user/unfollow/${userName}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + }); + + if (response.ok) { + setConfirmationMessage("You have unfollowed the user."); + setIsFollowing(false) + setToastData({ + message: `You' have unfollowed ${userName}.`, + variant: 'secondary', + show: true, + }); + } else { + throw new Error('There was an error unfollowing the user.'); + } + } catch (error) { + console.error('Error unfollowing user:', error); + setConfirmationMessage(error.toString()); + setShowConfirmation(true); + } + setShowUnfollowModal(false); + }; + + const recipeUrl = `www.kasula.live/RecipeDetail/${id}`; + const message = encodeURIComponent('Check this amazing recipe that I found in Kasulà! ' + recipeUrl); + + const shareOnWhatsApp = () => { + window.open(`https://wa.me/?text=${message}`, '_blank'); + }; + + const shareOnLinkedIn = () => { + window.open(`https://www.linkedin.com/shareArticle?mini=true&url=${recipeUrl}&title=${encodeURIComponent('Mira esta receta que chula!')}`, '_blank'); + }; + + const shareOnTwitter = () => { + window.open(`https://twitter.com/intent/tweet?text=${message}`, '_blank'); + }; + + const copyToClipboard = () => { + const url = window.location.href; + navigator.clipboard.writeText(url).then(() => { + setToastData({ + message: "Link copied to clipboard!", + variant: "success", + show: true, + }); + }, (err) => { + console.error('Could not copy text: ', err); + }); + }; + return (

{recipe.name}

- -
handleNavigate(recipe.user_id)}> - - - {'Author:'} - - - - - - {userName} - - -
-

More information

@@ -307,15 +413,49 @@ function RecipeDetail() { {recipe.energy ?? "No info of"} kcal
- - + +
handleNavigate(null, recipe.user_id)}> + + + + + +

{userName}

+ + + {!adminMode && ( + + )} + +
+
+ +
+ + + + + + + + WhatsApp + + + LinkedIn + + + Twitter + + + Copy + + + @@ -418,10 +578,31 @@ function RecipeDetail() {
+ + + + +

Similar Recipes

+ + {similarRecipes.length > 0 ? similarRecipes?.map((recipe) => ( + + + + + + )): null} +
+
+
- {/* Offcanvas para mostrar los comentarios */} + + + setShowReviews(false)} @@ -431,7 +612,7 @@ function RecipeDetail() { Reviews - + {toastData.message} + + setShowUnfollowModal(false)}> + + Unfollow User + + Do you want to unfollow this user? + + + + + + + setShowLoginRedirectModal(false)}> + + Required log in + + You need to log in to follow this user. + + + + + + ); } -export default RecipeDetail; +export default RecipeDetail; \ No newline at end of file diff --git a/src/components/RecipeFilters.jsx b/src/components/RecipeFilters.jsx new file mode 100644 index 0000000..902627f --- /dev/null +++ b/src/components/RecipeFilters.jsx @@ -0,0 +1,147 @@ +import { useState } from "react"; +import { Container, Button, Form } from "react-bootstrap"; +import { SortUp, SortDown, X, Funnel } from "react-bootstrap-icons"; +import StarSelector from "./StarSelector"; +import sortOptions from "../assets/jsonData/sort_options.json"; + +function RecipeFilters({ onClose, inValues }) { + const defaultFilters = { + sortBy: "none", + sortAscending: false, + maxDifficulty: 5, + maxTime: 180, + minRating: 0, + maxCalories: 5000, + }; + + const [filters, setFilters] = useState(inValues || defaultFilters); + + return ( + + {JSON.stringify(filters) !== JSON.stringify(defaultFilters) && ( +
setFilters(defaultFilters)} + > + + Clear filters + +
+ )} +
+ + Sort By +
+ + {sortOptions.fields.map((option) => ( + + ))} + + { + setFilters({ + ...filters, + sortAscending: !filters.sortAscending, + }); + }} + > + {filters.sortAscending ? : } + +
+
+ + Difficulty +
+ + setFilters({ ...filters, maxDifficulty: value }) + } + /> +
+
+ + Rating +
+ setFilters({ ...filters, minRating: value })} + /> +
+
+ + Time +
+ + setFilters({ ...filters, maxTime: e.target.value }) + } + /> +
+ {filters.maxTime} min +
+
+
+
+ 0 min + 180 min +
+ + Calories +
+ + setFilters({ ...filters, maxCalories: e.target.value }) + } + /> +
+ {filters.maxCalories} cal +
+
+
+
+ 0 cal + 5000 cal +
+
+ +
+
+
+ ); +} + +export default RecipeFilters; diff --git a/src/components/RecipeList.jsx b/src/components/RecipeList.jsx index c94d110..353acb2 100644 --- a/src/components/RecipeList.jsx +++ b/src/components/RecipeList.jsx @@ -1,31 +1,43 @@ -//React -import { CSSTransition } from "react-transition-group"; -//Bootstrap +import { CSSTransition } from "react-transition-group"; +import { useRef, useEffect } from "react"; import { Link } from "react-router-dom"; import { Container, Row, Col } from "react-bootstrap"; import { X } from "react-bootstrap-icons"; - -//Components import RecipeCard from "./RecipeCard"; - -//CSS import "../css/Transitions.css"; -function recipeList({ recipes, canDelete, onDeleteRecipe, id, token }) { +function RecipeList({ recipes, canDelete, onDeleteRecipe, onRequestLoadMore, id, token, finished }) { + const myRef = useRef(); + useEffect(() => { + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !finished) { + onRequestLoadMore(); + observer.unobserve(entries[0].target); + } + }); + if (myRef.current) { + observer.observe(myRef.current); + } + return () => { + if (myRef.current) { + observer.unobserve(myRef.current); + } + }; + }, [myRef, recipes]); return ( {recipes && recipes.length > 0 ? ( - recipes.map((recipe) => ( - + recipes.map((recipe, index) => ( + -
+
- There are currently no recipes + {canDelete ? "There are currently no recipes" : "There are no recipes matching the search or filters"}
)} @@ -75,4 +87,4 @@ function recipeList({ recipes, canDelete, onDeleteRecipe, id, token }) { ); } -export default recipeList; +export default RecipeList; diff --git a/src/components/Reviews.jsx b/src/components/Reviews.jsx index d8b6fc5..265ff5d 100644 --- a/src/components/Reviews.jsx +++ b/src/components/Reviews.jsx @@ -1,20 +1,24 @@ import React, { useState, useEffect } from "react"; import { Container, Row, Col, Button, Image } from "react-bootstrap"; import PostReview from "./PostReview"; -import chefIcon from "../assets/icons/chef.png"; -import { StarFill, PatchCheck } from "react-bootstrap-icons"; +import { StarFill, PatchCheck, PencilSquare, Trash } from 'react-bootstrap-icons'; +import { useNavigate } from "react-router-dom"; import LikesReview from "./LikesReview"; import ImageModal from "./ImageModal"; - +import ModifyReview from "./ModifyReview"; function Reviews(props) { - const { id, reloadReviews } = props; + const { id, reloadReviews, owner } = props; const [reviews, setReviews] = useState(null); const [showModal, setShowModal] = useState(false); const [showModalImage, setShowModalImage] = useState(false); + const [showModalReview, setShowModalReview] = useState(false); const [selectedImage, setSelectedImage] = useState(null); + const [selectedReview, setSelectedReview] = useState(null); + const [selectedFunct, setSelectedFunct] = useState(null); const isLogged = window.localStorage.getItem("logged"); - const currentUserUsername = localStorage.getItem('currentUser'); + const currentUser = localStorage.getItem('currentUser'); + const navigate = useNavigate(); useEffect(() => { @@ -36,7 +40,7 @@ function Reviews(props) { setShowModalImage(false); }; - const handleOpenModal = (image) => { + const handleOpenModal = () => { setShowModal(true); }; @@ -44,12 +48,30 @@ function Reviews(props) { setShowModal(false); }; + const handleOpenModalReview = (review, funct) => { + setSelectedReview(review); + setShowModalReview(true); + setSelectedFunct(funct); + + }; + + const handleCloseModalReview = () => { + setSelectedReview(null); + setShowModalReview(false); + setSelectedFunct(null); + }; + + const handleNavigate = (userId) => { + navigate(`/UserProfile/${userId}`); + }; + return ( {isLogged === 'true' ? - + ) : null} : null}
    - {reviews ? ( + {reviews && reviews.length > 0 ? ( reviews.map((review, index) => (
  • + {review.image ? - - {review.username}:{" "} + +
    handleNavigate(review.username)}> + {review.username}:{" "} +
    - {review.comment} + {review.comment} + /> + {currentUser === review.username && ( + <> + handleOpenModalReview(review, 'Edit')} + /> + handleOpenModalReview(review, 'Trash')} + /> + + )} +
+ +
+ + + : + + +
handleNavigate(review.username)}> + {review.username}:{" "} +
+ + {review.comment} + + + +
+
+ {" "} + {Array(review.rating || 0) + .fill() + .map((_, index) => ( + + + + ))} +
+
+ + +
+ + {currentUser === review.username && ( + <> + handleOpenModalReview(review, 'Edit')} + /> + handleOpenModalReview(review, 'Trash')} + /> + + )}
-
+ } )) - ) : ( - Reviews not available + ) : ( owner !== currentUser ? + (
+

There are currently no reviews

+

Be the first one to post a review and share your thoughts!

+ + +
) :
+

There are currently no reviews

+

You are the owner of the recipe you can't do reviews

+
)}
@@ -133,8 +248,19 @@ function Reviews(props) { show={showModalImage} onHide={handleCloseModalImage} recipeImage={selectedImage} - recipeName={selectedImage ? "Image" : null} // Puedes cambiar esto según tus necesidades + recipeName={selectedImage ? "Image" : null} /> + {selectedReview && ( + + )} ); } diff --git a/src/components/Root.js b/src/components/Root.js index 85a00e2..5944796 100644 --- a/src/components/Root.js +++ b/src/components/Root.js @@ -22,6 +22,7 @@ import Notifications from "./Notifications"; import "../css/Transitions.css"; import "../css/Root.css"; import "../css/common.css"; +import "../css/slider.css"; function Root() { return ( diff --git a/src/components/SimilarRecipes.jsx b/src/components/SimilarRecipes.jsx new file mode 100644 index 0000000..89a9731 --- /dev/null +++ b/src/components/SimilarRecipes.jsx @@ -0,0 +1,52 @@ +import { Card, Image, Row, Col } from "react-bootstrap"; +import { StarFill } from "react-bootstrap-icons"; +import "../css/UserFeed.css"; +import chefIcon from "../assets/icons/chef.png"; +import gyoza from "../assets/gyozas.jpg"; + +function SimilarRecipes({ recipe }) { + return ( + + + + + + + {recipe.name} + + + + +
+ {Array(recipe.difficulty || 0) + .fill() + .map((_, index) => ( + + + + ))} +
+ + + + {recipe?.average_rating?.toFixed(1) || 0} + +
+
+ By {recipe?.username} +
+ ); + + } + + export default SimilarRecipes; diff --git a/src/components/StarSelector.jsx b/src/components/StarSelector.jsx new file mode 100644 index 0000000..87ac0e2 --- /dev/null +++ b/src/components/StarSelector.jsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { Star, StarFill } from "react-bootstrap-icons"; + +function StarSelector({ onSelect, maxValue, value, starSize, type }) { + const [hoveredStar, setHoveredStar] = useState({ + value: 0, + active: false, + }); + let stars = []; + for (let i = 1; i <= maxValue; i++) { + const isActive = i <= value; + const isHovered = i <= hoveredStar.value && hoveredStar.active; + const aboveHovered = i > hoveredStar.value && hoveredStar.active; + stars.push( + { + onSelect((isActive && i === value ? i - 1 : i)); + }} + onMouseEnter={() => setHoveredStar({ value: i, active: true })} + onMouseLeave={() => setHoveredStar({ value: 0, active: false })} + > + {isActive || isHovered ? : } + + ); + } + return stars; +} + +export default StarSelector; diff --git a/src/components/UserFeed.jsx b/src/components/UserFeed.jsx index 4d359d0..608bd73 100644 --- a/src/components/UserFeed.jsx +++ b/src/components/UserFeed.jsx @@ -1,43 +1,250 @@ -//React import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "./AuthContext"; import RecipeList from "./RecipeList"; - -//Bootstrap -import { Container, Spinner } from "react-bootstrap"; +import RecipeBrowser from "./RecipeBrowser"; +import { Container, Spinner, ButtonGroup, Button, Modal, Row, Col } from "react-bootstrap"; function UserFeed() { + const {token, isLogged } = useAuth(); + const navigate = useNavigate(); + const numRecipes = isLogged() ? 24 : 9; const [recipes, setRecipes] = useState([]); const [loading, setLoading] = useState(true); + const [feedType, setFeedType] = useState(localStorage.getItem('feedType') || 'foryou'); + const [showLoginRedirectModal, setShowLoginRedirectModal] = useState(false); + const [myFollowing, setMyFollowing] = useState(0); + const hasFollowings = myFollowing.length > 0; + + const loggedOutFilters = { + sortBy: "average_rating", + sortAscending: false, + }; + const [filters, setFilters] = useState(isLogged() ? (JSON.parse(localStorage.getItem("filters"))) : loggedOutFilters); + const [recipeName, setRecipeName] = useState(null); + const [page, setPage] = useState(0); + const [finished, setFinished] = useState(isLogged() ? false : true); useEffect(() => { - getRecipes(); - }, []); + if (!isLogged()) { + setFinished(true); + setPage(0); + setFilters(loggedOutFilters); + setRecipeName(null); + console.error("hola") + getRecipesLogout(loggedOutFilters, null, 0, 9, true, feedType); + } + fetchMyUserData() + }, [isLogged]); + + useEffect(() => { + getRecipes(filters, recipeName, page, numRecipes, true, feedType); + }, [feedType]); + + const fetchMyUserData = async () => { + try { + const response = await fetch(process.env.REACT_APP_API_URL + '/user/me', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + }); + if (!response.ok) { + throw new Error('Failed to fetch user data'); + } + const data = await response.json(); + setMyFollowing(data.following) + } catch (error) { + console.error('Error fetching user data:', error); + } + }; + + const getRecipesLogout = async (filters, recipeName, page, numRecipes, reset, feedType) => { + setLoading(true); + let url = buildRequestUrl(filters, recipeName, page, numRecipes, feedType); + try { + const response = await fetch(url, { + method: 'GET', + }); + if (!response.ok) { + if (response.status === 400) { + setPage(page - 1); + setFinished(true); + } + throw new Error('Failed to fetch recipes'); + } + const data = await response.json(); + console.log(data) + if (reset) { + setRecipes(data) + } else { + setRecipes(recipes.concat(data)); + } + } catch (error) { + console.error('Error fetching recipes:', error); + } finally { + setLoading(false); + } + }; - const getRecipes = () => { + const getRecipes = async (filters, recipeName, page, numRecipes, reset, feedType) => { setLoading(true); - fetch(process.env.REACT_APP_API_URL + "/recipe/") - .then((response) => response.json()) - .then((data) => { - console.log(data) - setRecipes(data); - setLoading(false); - }) - .catch((error) => { - console.error("Error al obtener recetas:", error); - setLoading(false); + let url = buildRequestUrl(filters, recipeName, page, numRecipes, feedType); + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, }); - } + if (!response.ok) { + if (response.status === 400) { + setPage(page - 1); + setFinished(true); + } + throw new Error('Failed to fetch recipes'); + } + const data = await response.json(); + console.log(data) + if (reset) { + setRecipes(data) + } else { + setRecipes(recipes.concat(data)); + } + } catch (error) { + console.error('Error fetching recipes:', error); + } finally { + setLoading(false); + } + }; + + const buildRequestUrl = (filters, recipeName, page, numRecipes, feedType) => { + let url = process.env.REACT_APP_API_URL + "/recipe/" + (filters || recipeName || page || numRecipes ? "magic?" : ""); + if (filters) { + url += (filters.sortBy === 'none') ? '' : `sort_by=${filters.sortBy}&`; + url += (filters.sortBy === 'none') ? '' : `order=${filters.sortAscending}&`; + url += filters.maxDifficulty ? `max_difficulty=${filters.maxDifficulty}&` : ''; + url += filters.maxTime ? `max_cooking_time=${filters.maxTime}&` : ''; + url += filters.minRating ? `min_rating=${filters.minRating}&` : ''; + url += filters.maxCalories ? `max_energy=${filters.maxCalories}&` : ''; + } + if (recipeName) { + url += `search=${recipeName}&`; + } + if (page !== null && numRecipes !== null) { + url += `start=${page * numRecipes}&`; + url += `size=${numRecipes}&`; + } + url += `feedType=${feedType}`; + console.error(url) + return url; + }; + + const handleTabChange = (tab) => { + setPage(0); + setFeedType(tab); + setFinished(false); + if(isLogged()){ + setRecipes([]); + } + localStorage.setItem('feedType', tab); + if (!isLogged() && tab === "following") { + setShowLoginRedirectModal(true); + setFeedType('foryou'); + return; + } + getRecipes(filters, recipeName, 0, numRecipes, true, tab); + }; return ( - loading ? ( - - - - ) : ( - - ) - ); + + + + + + + + + + + + + {isLogged() && ( + { + setPage(0); + setFinished(false); + setFilters(newFilters); + setRecipeName(newRecipeName); + getRecipes(newFilters, newRecipeName, 0, numRecipes, true, feedType); + }}/> + )} + { + feedType === 'foryou' || (feedType === "following" && hasFollowings) ? ( + loading && recipes.length === 0 ? ( + + + + ) : ( + isLogged && ( + { + setPage(page + 1); + if (feedType === "foryou") { + getRecipes(filters, recipeName, page+1, numRecipes, false, 'foryou'); + } else { + getRecipes(filters, recipeName, page+1, numRecipes, false, 'following'); + } + }} + finished={finished} + /> + ) + ) + ) : ( +
+ You still don't follow anyone! +
+ ) + } + + {!isLogged() && ( +
+ This is as far as you can go. Please, login or register to see more recipes. +
+ )} + + + setShowLoginRedirectModal(false)}> + + Required log in + + You need to log in to view the recipes of the people you follow. + + + + + +
+ ); } -export default UserFeed; +export default UserFeed; \ No newline at end of file diff --git a/src/components/UserProfile.jsx b/src/components/UserProfile.jsx index ecb1c5a..bf5dc47 100644 --- a/src/components/UserProfile.jsx +++ b/src/components/UserProfile.jsx @@ -12,12 +12,16 @@ import chefIcon from "../assets/icons/chef.png"; import logo from "../assets/logo.png"; // Bootstrap -import { StarFill, Pencil, ExclamationTriangleFill, X} from "react-bootstrap-icons"; +import { StarFill, Pencil, ExclamationTriangleFill, X, GearFill} from "react-bootstrap-icons"; import { Container, Row, Col, Card, Button, Form, Image, Modal, Dropdown, ListGroup } from "react-bootstrap"; // CSS import "../css/common.css"; import "../css/UserProfile.css"; +import PrivacySettings from "./PrivacySettings"; + +//Components +import PostRecipe from "./PostRecipe"; const UserProfile = () => { const { token, logout, isLogged } = useAuth(); @@ -26,6 +30,7 @@ const UserProfile = () => { const { userId } = useParams(); + const [updateCount, setUpdateCount] = useState(0); const [myUserId, setMyUserId] = useState(''); const [myUserName, setMyUserName] = useState(''); const [userName, setUserName] = useState(''); @@ -33,6 +38,7 @@ const UserProfile = () => { const [userBio, setUserBio] = useState(''); const [userFollowers, setUserFollowers] = useState([]); const [userFollowing, setUserFollowing] = useState([]); + const [myFollowing, setMyFollowing] = useState([]); const [userNameAux, setUserNameAux] = useState(''); const [userMailAux, setUserMailAux] = useState(''); const [userBioAux, setUserBioAux] = useState(''); @@ -49,12 +55,17 @@ const UserProfile = () => { const [isFollowing, setIsFollowing] = useState(null); const [followerDetails, setFollowerDetails] = useState([]); const [followingDetails, setFollowingDetails] = useState([]); + const [imPrivate, setImPrivate] = useState(true); + const [userIsPrivate, setUserIsPrivate] = useState(true); + const [suggestedUsers, setSuggestedUsers] = useState([]); const [adminMode, setadminMode] = useState(false); const [editMode, setEditMode] = useState(false); const [showConfirmation, setShowConfirmation] = useState(false); const [showDropdown, setShowDropdown] = useState(false); + const [showPrivacySettings, setShowPrivacySettings] = useState(false); + const [showEditRecipe, setShowEditRecipe] = useState(false); const [showDropdown2, setShowDropdown2] = useState(false); const [showRemoveQuestion, setRemoveQuestion] = useState(false); const [showRemoveRecipeModal, setShowRemoveRecipeModal] = useState(false); @@ -107,11 +118,17 @@ const UserProfile = () => { const data = await response.json(); setMyUserId(data._id) setMyUserName(data.username) + setImPrivate(data.is_private) + setMyFollowing(data.following) } catch (error) { console.error('Error fetching user data:', error); } }; + const isFollowed = (follower) => { + return myFollowing.includes(follower.username); + }; + const fetchUserData = async () => { try { const response = await fetch(process.env.REACT_APP_API_URL + '/user/' + userId, { @@ -139,6 +156,8 @@ const UserProfile = () => { setUserFollowing(data.following || []); setProfilePicture(data.profile_picture || ''); + + setUserIsPrivate(data.is_private); } catch (error) { console.error('Error fetching user data:', error); } @@ -154,13 +173,34 @@ const UserProfile = () => { setUserDetails(userDetails); }; + const fetchSuggestedUsers = async () => { + try { + const response = await fetch(process.env.REACT_APP_API_URL + `/user/new/discover`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + }); + if (!response.ok) { + throw new Error('Failed to fetch suggested users'); + } + const data = await response.json(); + setSuggestedUsers(data); + } catch (error) { + console.error('Error fetching suggested users:', error); + } + }; + useEffect(() => { - if (isLogged) { + if (token!=null) { fetchMyUserData(); + fetchSuggestedUsers(); } - }, [token, navigate]); + }, [token]); useEffect(() => { + if(myUserId == userId){ setadminMode(true); } @@ -171,10 +211,11 @@ const UserProfile = () => { if (userName) { getRecipes(); } - }, [userName]); + }, [userName, updateCount]); useEffect(() => { fetchUserDetails(userFollowers, setFollowerDetails); + console.error(followerDetails) }, [userFollowers]); useEffect(() => { @@ -191,10 +232,28 @@ const UserProfile = () => { setEditMode(false); }; + const handleCloseEditRecipeModal = () => { + setShowEditRecipe(false); + setUpdateCount(prevCount => prevCount + 1); + }; + + const handleCloseEditRecipeSuccessfulModal = () => { + setShowEditRecipe(false); + setUpdateCount(prevCount => prevCount + 1); + }; + const handleShowRemoveRecipeModal = () => { setShowRemoveRecipeModal(true); }; + const handleOpenPrivacySettings = () => { + setShowPrivacySettings(true); + }; + + const handleClosePrivacySettings = () => { + setEditMode(false); + }; + const handleSaveProfile = async () => { if (!userNameAux) { @@ -239,19 +298,52 @@ const UserProfile = () => { setUserName(updatedData.username); setUserMail(updatedData.email); setUserBio(updatedData.bio || ''); + setOperationSuccess(true) setConfirmationMessage("Profile updated successfully"); } catch (error) { console.error('Error updating user data:', error); - setConfirmationMessage("Failed to update profile"); + setOperationSuccess(false) + setConfirmationMessage("Oops! Something went wrong."); } setShowConfirmation(true); - setOperationSuccess(true) setEditMode(false); }; +const handleVisibilityChange = async (newVisibility) => { + console.error(newVisibility) + setImPrivate(newVisibility); + const formData = new FormData(); + const userProfile = { + is_private: newVisibility, + }; + + // Convert the user profile object to a JSON string and append to FormData + formData.append('user', JSON.stringify(userProfile)); + + try { + const response = await fetch(process.env.REACT_APP_API_URL + `/user/${userId}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData, + }); + + if (!response.ok) { + throw new Error('Failed to update user data'); + } + + const updatedData = await response.json(); + } catch (error) { + console.error('Error updating user data:', error); + setConfirmationMessage("Failed to update profile"); + } +}; + + const handleImageUpload = async () => { const userData = { }; @@ -277,14 +369,14 @@ const UserProfile = () => { }); if (!response.ok) { - throw new Error('Failed to update user data'); + throw new Error('Oops! Something went wrong.'); } const data = await response.json(); setConfirmationMessage("Profile updated successfully"); } catch (error) { console.error('Error updating user data:', error); - setConfirmationMessage("Failed to update profile"); + setConfirmationMessage("Oops! Something went wrong."); } setShowConfirmation(true); } @@ -353,6 +445,8 @@ const UserProfile = () => { setUserNameAux(value); setUsernameValid(isUsernameValid(value)); setUsernameValidated(true); + console.error(usernameValid==true) + console.error("hola") }; const onEmailChange = (e) => { @@ -484,11 +578,51 @@ const UserProfile = () => { useEffect(() => { setIsFollowing(userFollowers.includes(myUserName)); - console.error(userFollowers) }, [userFollowers]); + const checkIfFollowed = (username) => { + return userFollowing.includes(username); + }; + + const handleFollowUnfollow = async (username, isCurrentlyFollowed) => { + const url = process.env.REACT_APP_API_URL + `/user/${isCurrentlyFollowed ? 'unfollow' : 'follow'}/${username}`; + const method = 'POST'; + + try { + const response = await fetch(url, { + method, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + }); + + if (!response.ok) { + throw new Error(`Failed to ${isCurrentlyFollowed ? 'unfollow' : 'follow'} user`); + } + + setMyFollowing(prevFollowing => { + return isCurrentlyFollowed + ? prevFollowing.filter(user => user !== username) + : [...prevFollowing, username]; + }); + + if(myUserName===userName){ + setUserFollowing(prevFollowing => { + return isCurrentlyFollowed + ? prevFollowing.filter(user => user !== username) + : [...prevFollowing, username]; + }) + } + } catch (error) { + console.error('Error:', error); + } + }; + + + const handleFollow = async () => { - if (isLogged) { + if (token==null) { setShowLoginRedirectModal(true); return; } @@ -571,16 +705,27 @@ const UserProfile = () => { )} - + -

@{userName}

+ +

@{userName}

+ + + + + {adminMode && ( + + )} +
{userBio}
{/* Followers Section */} -
+

Followers

{userFollowers.length}

@@ -589,7 +734,7 @@ const UserProfile = () => {
{/* Following Section */} -
+

Following

{userFollowing.length}

@@ -599,13 +744,6 @@ const UserProfile = () => { - - {adminMode && ( - - )} - @@ -613,6 +751,7 @@ const UserProfile = () => { @@ -622,48 +761,54 @@ const UserProfile = () => { - {recipes && recipes.length > 0 ? ( + {!userIsPrivate || adminMode ? + ( + recipes && recipes.length > 0 ? ( recipes.map((recipe) => ( - - - - - - - - - {recipe.name} -
- - {Array(recipe.difficulty || 0).fill().map((_, index) => ( - - ))} -
- {adminMode && ( -
- - -
- )} -
-
- -
- + + + + + + + + {recipe.name} +
+ + {Array(recipe.difficulty || 0).fill().map((_, index) => ( + + ))} +
+ {adminMode && ( +
+ + +
+ )} +
+
+
+ )) - ) : ( + ) : (
There are currently no Recipes
- )} + ) + ) : ( +
The user has a private profile. You cannot see his recipes.
+ ) + } + +
{} @@ -675,10 +820,10 @@ const UserProfile = () => { setShowRemoveRecipeModal(false)}> - + Remove recipe - + @@ -690,7 +835,7 @@ const UserProfile = () => { - + @@ -701,79 +846,23 @@ const UserProfile = () => { show={showConfirmation} onHide={handleConfirmationClose} > - + Profile Update - {confirmationMessage} - + {confirmationMessage} + - - - - Edit Profile - - -
- - Name - onUsernameChange(e)} - isInvalid={usernameValidated && !usernameValid} - /> - - {usernameValidationMessage} - - - - Email - onEmailChange(e)} - isInvalid={emailValidated && !emailValid} - /> - - {emailValidationMessage} - - - - Biography - setUserBioAux(e.target.value)} - /> - -
-
- - - - -
- - + {operationSuccess ? 'Success' : 'Error'} - {confirmationMessage} - + {confirmationMessage} + @@ -781,12 +870,12 @@ const UserProfile = () => { - + Followers - + {followerDetails.length > 0 ? ( followerDetails.map((follower, index) => ( @@ -800,9 +889,23 @@ const UserProfile = () => { style={{ width: '30px', marginRight: '10px' }} /> - + {follower.username} + + {(token !== null) && (follower.username !== myUserName) && ( + + )} + +
@@ -816,43 +919,87 @@ const UserProfile = () => { - + Following - - {followingDetails.length > 0 ? ( - followingDetails.map((follower, index) => ( - - handleNavigate(follower._id)}> + + {userFollowing.length > 0 ? ( + followingDetails.map((following, index) => ( + + handleNavigate(following._id)}> - + - - {follower.username} + + {following.username} + + + {(token !== null) && (following.username !== myUserName) && ( + + )} - )) + )) + ) : ( + adminMode ? ( + <> +

You're not following anyone. Discover creators that match your taste!

+ {suggestedUsers.length > 0 ? ( + suggestedUsers.slice(0, 5).map((user, index) => ( + + handleNavigate(user._id)}> + + + + + + + {user.username} + + + + + + )) + ) : ( +

Loading...

+ )} + + ) : ( -

There are no users to display.

- )} +

You are not following anyone yet.

+ ) + )}
setShowUnfollowModal(false)}> - + Unfollow User - Do you want to unfollow this user? - + Do you want to unfollow this user? + @@ -867,7 +1014,7 @@ const UserProfile = () => { Required log in You need to log in to follow this user. - + @@ -877,10 +1024,52 @@ const UserProfile = () => { + + + Profile Settings + + + + + + + + + Edit Recipe + + + + + +
); }; export default UserProfile; - diff --git a/src/css/PostRecipe.css b/src/css/PostRecipe.css index ee63642..36dc33a 100644 --- a/src/css/PostRecipe.css +++ b/src/css/PostRecipe.css @@ -3,7 +3,15 @@ } .difficulty-star.active:hover { - color: rgb(151, 114, 1); + color: rgb(151, 91, 1); +} + +.difficulty-star.active.hovered { + color: rgb(151, 91, 1); +} + +.difficulty-star.active.above-hovered { + color: #7c7c7c; } .difficulty-star.inactive { @@ -14,6 +22,10 @@ color: #7c7c7c; } +.difficulty-star.inactive.hovered { + color: rgb(151, 91, 1); +} + .add-ingredient-button.active { color: rgb(0, 194, 0); } diff --git a/src/css/common.css b/src/css/common.css index dd52aec..6211ba7 100644 --- a/src/css/common.css +++ b/src/css/common.css @@ -123,4 +123,51 @@ .my-dropdown-item:hover { background-color: transparent !important; - } \ No newline at end of file + } +#search-bar { + border-right: none; + transition: none; +} + +#search-bar-clear { + background-color: white; + border-left: none; + cursor: text; + transition: none; +} + +#search-bar-container:focus-within #search-bar { + border-color: #80bdff; +} + +#search-bar-container:focus-within #search-bar-clear { + border-color: #80bdff; +} + +.rating-star.active { + color: #e0b700; +} + +.rating-star.active:hover { + color: rgb(151, 124, 43); +} + +.rating-star.active.hovered { + color: rgb(151, 124, 43); +} + +.rating-star.active.above-hovered { + color: #7c7c7c; +} + +.rating-star.inactive { + color: #a7a7a7; +} + +.rating-star.inactive:hover { + color: #7c7c7c; +} + +.rating-star.inactive.hovered { + color: rgb(151, 124, 43); +} diff --git a/src/css/slider.css b/src/css/slider.css new file mode 100644 index 0000000..edc0470 --- /dev/null +++ b/src/css/slider.css @@ -0,0 +1,37 @@ +input[type=range]::-webkit-slider-runnable-track { + background: #ddd; +} + +input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + background: rgb(255, 74, 42); +} + +input[type=range]:focus { + outline: none; +} + +input[type=range]:focus::-webkit-slider-runnable-track { + background: #ffafaf; +} + +input[type=range]::-moz-range-track { + background: #ffafaf; +} + +input[type=range]::-moz-range-thumb { + background: rgb(255, 74, 42); +} + +input[type=range]:focus::-moz-range-thumb { + background: rgb(155, 26, 4); +} + +/*hide the outline behind the border*/ +input[type=range]:-moz-focusring { + outline: none; +} + +input[type=range]:focus::-moz-range-track { + background: #ffc0c0; +} \ No newline at end of file