diff --git a/api/assets/config/emails.js b/api/assets/config/emails.js index 83dc957..2d277d2 100644 --- a/api/assets/config/emails.js +++ b/api/assets/config/emails.js @@ -1,4 +1,65 @@ -exports.emailTemplate = (subtitle, buttonText, url, footerText) => ` +exports.emailQuoteTemplate = (isValid, quote, frontendLink) => ` +
+ + + + + + +
+ + + + + + + + + +
+ + + + + + +
+

FunctionProject

+
+
+ + + + + + +
+

+ La citation que vous avez proposée a été ${(isValid) ? "validée" : "supprimée"}. +

+

+ Lien vers la fonction randomQuote de FunctionProject. +

+ ${(!isValid) ? ` +

+ Si votre citation a été supprimée et vous pensez que c'est une erreur, contactez-moi à cette adresse email : contact@divlo.fr. +

+ ` : ""} +
+

+ La citation en question :
+ " ${quote.quote} " +
+ - ${quote.author} +

+
+
+
+
+
+`; + +exports.emailUserTemplate = (subtitle, buttonText, url, footerText) => `
diff --git a/api/assets/functions/main/randomQuote.js b/api/assets/functions/main/randomQuote.js index 66c3027..789c3b0 100644 --- a/api/assets/functions/main/randomQuote.js +++ b/api/assets/functions/main/randomQuote.js @@ -14,6 +14,9 @@ module.exports = randomQuote = async ({ res, next }, _argsObject) => { attributes: { exclude: ["isValidated"] }, + where: { + isValidated: 1, + } }); return res.status(200).json(quote); } catch (error) { diff --git a/api/controllers/admin.js b/api/controllers/admin.js index 1495f6e..ad4e47c 100644 --- a/api/controllers/admin.js +++ b/api/controllers/admin.js @@ -1,15 +1,18 @@ -const path = require('path'); -const fs = require('fs'); -const { validationResult } = require('express-validator'); -const errorHandling = require('../assets/utils/errorHandling'); -const { serverError } = require('../assets/config/errors'); -const Functions = require('../models/functions'); -const Categories = require('../models/categories'); -const Quotes = require('../models/quotes'); -const Users = require('../models/users'); -const helperQueryNumber = require('../assets/utils/helperQueryNumber'); -const Sequelize = require('sequelize'); -const deleteFilesNameStartWith = require('../assets/utils/deleteFilesNameStartWith'); +const path = require('path'); +const fs = require('fs'); +const { validationResult } = require('express-validator'); +const errorHandling = require('../assets/utils/errorHandling'); +const { serverError } = require('../assets/config/errors'); +const Functions = require('../models/functions'); +const Categories = require('../models/categories'); +const Quotes = require('../models/quotes'); +const Users = require('../models/users'); +const helperQueryNumber = require('../assets/utils/helperQueryNumber'); +const Sequelize = require('sequelize'); +const deleteFilesNameStartWith = require('../assets/utils/deleteFilesNameStartWith'); +const { EMAIL_INFO, FRONT_END_HOST } = require('../assets/config/config'); +const transporter = require('../assets/config/transporter'); +const { emailQuoteTemplate } = require('../assets/config/emails'); const handleEditFunction = async (res, resultFunction, { title, slug, description, type, categorieId, isOnline }, imageName = false) => { resultFunction.title = title; @@ -305,18 +308,35 @@ exports.putQuote = async (req, res, next) => { if (typeof isValid !== 'boolean') { return errorHandling(next, { message: "isValid doit être un booléen.", statusCode: 400 }); } - const quote = await Quotes.findOne({ where: { id, isValidated: 0 } }); + const quote = await Quotes.findOne({ + where: { + id, + isValidated: 0 + }, + include: [ + { model: Users, attributes: ["name", "email"] } + ] + }); if (!quote) { return errorHandling(next, { message: "La citation n'existe pas (ou est déjà validé).", statusCode: 404 }); } + + await transporter.sendMail({ + from: `"FunctionProject" <${EMAIL_INFO.auth.user}>`, + to: quote.user.email, + subject: "FunctionProject - Citation proposée", + html: emailQuoteTemplate(isValid, quote, FRONT_END_HOST) + }); + if (isValid) { quote.isValidated = true; - const result = await quote.save(); - return res.status(200).json({ isValidated: true, message: "La citation a bien été validée!", result }); - } - - await quote.destroy(); - return res.status(200).json({ isValidated: false, message: "La citation a bien été supprimée!" }); + await quote.save(); + return res.status(200).json({ message: "La citation a bien été validée!" }); + } else { + await quote.destroy(); + return res.status(200).json({ imessage: "La citation a bien été supprimée!" }); + } + } catch (error) { console.log(error); return errorHandling(next, serverError); diff --git a/api/controllers/quotes.js b/api/controllers/quotes.js index dd88681..7cc6e40 100644 --- a/api/controllers/quotes.js +++ b/api/controllers/quotes.js @@ -1,8 +1,8 @@ -const errorHandling = require('../assets/utils/errorHandling'); -const { serverError } = require('../assets/config/errors'); -const Quotes = require('../models/quotes'); -const Users = require('../models/users'); -const helperQueryNumber = require('../assets/utils/helperQueryNumber'); +const errorHandling = require('../assets/utils/errorHandling'); +const { serverError, requiredFields } = require('../assets/config/errors'); +const Quotes = require('../models/quotes'); +const Users = require('../models/users'); +const helperQueryNumber = require('../assets/utils/helperQueryNumber'); exports.getQuotes = (req, res, next) => { const page = helperQueryNumber(req.query.page, 1); @@ -35,6 +35,10 @@ exports.getQuotes = (req, res, next) => { exports.postQuote = (req, res, next) => { const { quote, author } = req.body; + // S'il n'y a pas les champs obligatoire + if (!(quote && author)) { + return errorHandling(next, requiredFields); + } Quotes.create({ quote, author, userId: req.userId }) .then((_result) => { return res.status(200).json({ message: "La citation a bien été ajoutée, elle est en attente de confirmation d'un administrateur." }); diff --git a/api/controllers/users.js b/api/controllers/users.js index 8b6e8c9..80b8747 100644 --- a/api/controllers/users.js +++ b/api/controllers/users.js @@ -8,7 +8,7 @@ const errorHandling = require('.. const { serverError, generalError } = require('../assets/config/errors'); const { JWT_SECRET, FRONT_END_HOST, EMAIL_INFO, HOST, TOKEN_LIFE } = require('../assets/config/config'); const transporter = require('../assets/config/transporter'); -const { emailTemplate } = require('../assets/config/emails'); +const { emailUserTemplate } = require('../assets/config/emails'); const Users = require('../models/users'); const Favorites = require('../models/favorites'); const Functions = require('../models/functions'); @@ -28,7 +28,7 @@ async function handleEditUser(res, { name, email, biography, isPublicEmail }, us from: `"FunctionProject" <${EMAIL_INFO.auth.user}>`, to: email, subject: "FunctionProject - Confirmer l'email", - html: emailTemplate("Veuillez confirmer l'email", "Oui, je confirme.", `${HOST}/users/confirm-email/${tempToken}`, "Si vous avez reçu ce message par erreur, il suffit de le supprimer. Votre email ne serez pas confirmé si vous ne cliquez pas sur le lien de confirmation ci-dessus.") + html: emailUserTemplate("Veuillez confirmer l'email", "Oui, je confirme.", `${HOST}/users/confirm-email/${tempToken}`, "Si vous avez reçu ce message par erreur, il suffit de le supprimer. Votre email ne serez pas confirmé si vous ne cliquez pas sur le lien de confirmation ci-dessus.") }); } if (biography != undefined) { @@ -97,7 +97,7 @@ exports.register = async (req, res, next) => { from: `"FunctionProject" <${EMAIL_INFO.auth.user}>`, to: email, subject: "FunctionProject - Confirmer l'inscription", - html: emailTemplate("Veuillez confirmer l'inscription", "Oui, je m'inscris.", `${HOST}/users/confirm-email/${tempToken}`, "Si vous avez reçu ce message par erreur, il suffit de le supprimer. Vous ne serez pas inscrit si vous ne cliquez pas sur le lien de confirmation ci-dessus.") + html: emailUserTemplate("Veuillez confirmer l'inscription", "Oui, je m'inscris.", `${HOST}/users/confirm-email/${tempToken}`, "Si vous avez reçu ce message par erreur, il suffit de le supprimer. Vous ne serez pas inscrit si vous ne cliquez pas sur le lien de confirmation ci-dessus.") }); return res.status(201).json({ result: "Vous y êtes presque, veuillez vérifier vos emails pour confirmer l'inscription." }); } catch (error) { @@ -173,7 +173,7 @@ exports.resetPassword = async (req, res, next) => { from: `"FunctionProject" <${EMAIL_INFO.auth.user}>`, to: email, subject: "FunctionProject - Réinitialisation du mot de passe", - html: emailTemplate("Veuillez confirmer la réinitialisation du mot de passe", "Oui, je change mon mot de passe.", `${FRONT_END_HOST}/newPassword?token=${tempToken}`, "Si vous avez reçu ce message par erreur, il suffit de le supprimer. Votre mot de passe ne sera pas réinitialiser si vous ne cliquez pas sur le lien ci-dessus. Par ailleurs, pour la sécurité de votre compte, la réinitialisation du mot de passe est disponible pendant un délai de 1 heure, passez ce temps, la réinitialisation ne sera plus valide.") + html: emailUserTemplate("Veuillez confirmer la réinitialisation du mot de passe", "Oui, je change mon mot de passe.", `${FRONT_END_HOST}/newPassword?token=${tempToken}`, "Si vous avez reçu ce message par erreur, il suffit de le supprimer. Votre mot de passe ne sera pas réinitialiser si vous ne cliquez pas sur le lien ci-dessus. Par ailleurs, pour la sécurité de votre compte, la réinitialisation du mot de passe est disponible pendant un délai de 1 heure, passez ce temps, la réinitialisation ne sera plus valide.") }); return res.status(200).json({ result: "Demande de réinitialisation du mot de passe réussi, veuillez vérifier vos emails!" }); } catch (error) { diff --git a/website/package-lock.json b/website/package-lock.json index 46d7fa5..5f72ad4 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -1408,6 +1408,14 @@ "@fortawesome/fontawesome-common-types": "^0.2.28" } }, + "@fortawesome/free-brands-svg-icons": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-5.13.0.tgz", + "integrity": "sha512-/6xXiJFCMEQxqxXbL0FPJpwq5Cv6MRrjsbJEmH/t5vOvB4dILDpnY0f7zZSlA8+TG7jwlt12miF/yZpZkykucA==", + "requires": { + "@fortawesome/fontawesome-common-types": "^0.2.28" + } + }, "@fortawesome/free-regular-svg-icons": { "version": "5.13.0", "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.13.0.tgz", diff --git a/website/package.json b/website/package.json index d49b381..8e1c6f8 100644 --- a/website/package.json +++ b/website/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@fortawesome/fontawesome-svg-core": "^1.2.28", + "@fortawesome/free-brands-svg-icons": "^5.13.0", "@fortawesome/free-regular-svg-icons": "^5.13.0", "@fortawesome/free-solid-svg-icons": "^5.13.0", "@fortawesome/react-fontawesome": "^0.1.9", diff --git a/website/pages/admin/index.js b/website/pages/admin/index.js index 2835443..a157c0f 100644 --- a/website/pages/admin/index.js +++ b/website/pages/admin/index.js @@ -56,6 +56,9 @@ const Admin = (props) => { + + + } diff --git a/website/pages/admin/manageQuotes.js b/website/pages/admin/manageQuotes.js new file mode 100644 index 0000000..997998d --- /dev/null +++ b/website/pages/admin/manageQuotes.js @@ -0,0 +1,124 @@ +import { Fragment, useState, useEffect, useRef, useCallback } from 'react'; +import Cookies from "universal-cookie"; +import Link from 'next/link'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCheck, faTrash } from '@fortawesome/free-solid-svg-icons'; +import redirect from '../../utils/redirect'; +import HeadTag from '../../components/HeadTag'; +import api from '../../utils/api'; +import '../../public/css/pages/admin.css'; + +const manageQuotes = (props) => { + + const [quotesData, setQuotesData] = useState({ hasMore: true, rows: [], totalItems: 0 }); + const [isLoadingQuotes, setLoadingQuotes] = useState(true); + const [pageQuotes, setPageQuotes] = useState(1); + + // Récupère les citations si la page change + useEffect(() => { + getQuotesData(); + }, [pageQuotes]); + + const getQuotesData = async () => { + setLoadingQuotes(true); + const { data } = await api.get(`/admin/quotes?limit=20page=${pageQuotes}`, { headers: { 'Authorization': props.user.token } }); + setQuotesData({ + hasMore: data.hasMore, + rows: [...quotesData.rows, ...data.rows], + totalItems: data.totalItems + }); + setLoadingQuotes(false); + } + + // Permet la pagination au scroll + const observer = useRef(); + const lastQuoteRef = useCallback((node) => { + if (isLoadingQuotes) return; + if (observer.current) observer.current.disconnect(); + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && quotesData.hasMore) { + setPageQuotes(pageQuotes + 1); + } + }, { threshold: 1 }); + if (node) observer.current.observe(node); + }, [isLoadingQuotes, quotesData.hasMore]); + + if (!props.user.isAdmin && typeof window != 'undefined') { + return redirect({}, '/404'); + } + + const handleValidationQuote = async (id, isValid) => { + try { + await api.put(`/admin/quotes/${id}`, { isValid }, { headers: { 'Authorization': props.user.token } }); + window.location.reload(true); + } catch {} + } + + return ( + + + +
+
+
+

Liste des citations (non validées) :

+

Total de {quotesData.totalItems} citations.

+
+
+ +
+
+
+ + + + + + + + + + + {quotesData.rows.map((currentQuote, index) => { + const quoteJSX = ( + + + + + + + + ); + // Si c'est le dernier élément + if (quotesData.rows.length === index + 1) { + return {quoteJSX} + } + return {quoteJSX} + })} + +
Citation/ProverbeAuteurProposée parValiderSupprimer
{currentQuote.quote}{currentQuote.author} + + {currentQuote.user.name} + + handleValidationQuote(currentQuote.id, true)} className="Admin__table-row text-center" style={{ cursor: 'pointer' }}> + + handleValidationQuote(currentQuote.id, false)} className="Admin__table-row text-center" style={{ cursor: 'pointer' }}> + +
+ + + + + ); +} + +export async function getServerSideProps({ req }) { + const cookies = new Cookies(req.headers.cookie); + return { + props: { + user: { ...cookies.get('user') } + } + }; +} + +export default manageQuotes; \ No newline at end of file diff --git a/website/pages/functions/randomQuote.js b/website/pages/functions/randomQuote.js new file mode 100644 index 0000000..e6543cd --- /dev/null +++ b/website/pages/functions/randomQuote.js @@ -0,0 +1,341 @@ +import { Fragment, useState, useEffect, useContext, useRef, useCallback } from 'react'; +import HeadTag from '../../components/HeadTag'; +import Link from 'next/link'; +import { UserContext } from '../../contexts/UserContext'; +import FunctionComponentTop from '../../components/FunctionComponentTop'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTwitter } from '@fortawesome/free-brands-svg-icons'; +import SwipeableViews from 'react-swipeable-views'; +import redirect from '../../utils/redirect'; +import FunctionArticle from '../../components/FunctionArticle'; +import FunctionComments from '../../components/FunctionComments/FunctionComments'; +import htmlParser from 'html-react-parser'; +import Loader from '../../components/Loader'; +import api from '../../utils/api'; +import copyToClipboard from '../../utils/copyToClipboard'; +import { API_URL } from '../../utils/config'; +import '../../public/css/pages/FunctionComponent.css'; +import '../../components/FunctionTabs/FunctionTabs.css'; +import '../../components/FunctionCard/FunctionCard.css'; +import 'notyf/notyf.min.css'; + +const FunctionTabsTop = (props) => { + return ( +
+
+ +
+
+ ); +} + +const FunctionTabs = (props) => { + return ( +
+ props.setSlideIndex(index)} index={props.slideIndex} enableMouseEvents> + {props.children} + +
+ ); +} + +const GenerateQuote = () => { + + const [quote, setQuote] = useState({ quote: "", author: "" }); + + useEffect(() => { + getRandomQuote(); + }, []); + + const getRandomQuote = async () => { + const { data } = await api.post("/functions/randomQuote"); + setQuote(data); + } + + const handleCopyQuote = () => { + let Notyf; + if (typeof window != 'undefined') { + Notyf = require('notyf'); + } + const notyf = new Notyf.Notyf({ + duration: 5000 + }); + copyToClipboard(`"${quote.quote}" - ${quote.author}`); + notyf.success('Citation copiée dans le presse-papier!'); + } + + return ( +
+
+
+ + +
+
+
+
+

" {quote.quote} "

+

- {quote.author}

+
+
+
+ + Twitter + +
+
+ ); +} + +const QuoteList = () => { + + const [quotesData, setQuotesData] = useState({ hasMore: true, rows: [], totalItems: 0 }); + const [isLoadingQuotes, setLoadingQuotes] = useState(true); + const [pageQuotes, setPageQuotes] = useState(1); + + // Récupère les citations si la page change + useEffect(() => { + getQuotesData(); + }, [pageQuotes]); + + const getQuotesData = async () => { + setLoadingQuotes(true); + const { data } = await api.get(`/quotes?limit=20page=${pageQuotes}`); + setQuotesData({ + hasMore: data.hasMore, + rows: [...quotesData.rows, ...data.rows], + totalItems: data.totalItems + }); + setLoadingQuotes(false); + } + + // Permet la pagination au scroll + const observer = useRef(); + const lastQuoteRef = useCallback((node) => { + if (isLoadingQuotes) return; + if (observer.current) observer.current.disconnect(); + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && quotesData.hasMore) { + setPageQuotes(pageQuotes + 1); + } + }, { threshold: 1 }); + if (node) observer.current.observe(node); + }, [isLoadingQuotes, quotesData.hasMore]); + + return ( +
+
+
+

Liste des citations :

+

Total de {quotesData.totalItems} citations.

+
+
+ +
+
+ + + + + + + + + + {quotesData.rows.map((currentQuote, index) => { + const quoteJSX = ( + + + + + + ); + // Si c'est le dernier élément + if (quotesData.rows.length === index + 1) { + return {quoteJSX} + } + return {quoteJSX} + })} + +
Citation/ProverbeAuteurProposée par
{currentQuote.quote}{currentQuote.author} + + {currentQuote.user.name} + +
+
+
+
+ ); +} + +const SuggestQuote = () => { + + const { isAuth, user } = useContext(UserContext); + const [inputState, setInputState] = useState({ quote: "", author: "" }); + const [message, setMessage] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleChange = (event) => { + const inputStateNew = { ...inputState }; + inputStateNew[event.target.name] = event.target.value; + setInputState(inputStateNew); + } + + const handleSubmit = (event) => { + setIsLoading(true); + event.preventDefault(); + const token = user.token; + if (isAuth && token != undefined) { + api.post('/quotes', inputState, { headers: { 'Authorization': token } }) + .then(({ data }) => { + setInputState({ quote: "", author: "" }); + setMessage(`

Succès: ${data.message}

`); + setIsLoading(false); + }) + .catch((error) => { + setMessage(`

Erreur: ${error.response.data.message}

`); + setIsLoading(false); + }); + } + } + + return ( +
+ { + (isAuth) ? + +
+
+

Proposer une citation :

+

Vous pouvez proposer des citations, et une fois validé elles seront rajoutés à la liste des citations.

+
+
+
+
+
+
+ +