Compare commits

...

40 Commits
1.0 ... main

Author SHA1 Message Date
Guillaume Dorce f878fe314d fix image url when using different host 2022-11-18 10:17:27 +01:00
Guillaume Dorce 37181d714a fix fix error changeuserinfo 2022-11-18 09:38:09 +01:00
Guillaume Dorce 6d1072cc43 fix autoscroll and clean new message form 2022-11-12 22:50:58 +01:00
Guillaume Dorce b1a1590c1e fix home flex 2022-11-04 16:26:53 +01:00
Guillaume Dorce e17ac1f88f update build instructions 2022-11-04 16:06:40 +01:00
Guillaume Dorce 5e552a439f fix ts compile server 2022-11-04 16:03:36 +01:00
Guillaume Dorce dfea794acf add env exaple for api url 2022-11-04 15:36:37 +01:00
Guillaume Dorce 0397efba8a specify custom api url for client 2022-11-04 15:35:48 +01:00
Guillaume Dorce dd07580943 fix neutralino lib 2022-11-04 15:04:04 +01:00
Guillaume Dorce 829474b200 form responsive remove wrap 2022-11-04 14:50:27 +01:00
Guillaume Dorce ff532359f5 fix style for like 2022-11-04 14:48:48 +01:00
Guillaume Dorce 7b97687e78 fix style for like 2022-11-04 14:40:50 +01:00
Guillaume Dorce 55bae65444 fix problem with like increment 2022-11-04 14:26:45 +01:00
Guillaume Dorce 5889a81358 fix problem in likes number 2022-11-04 12:28:27 +01:00
Guillaume Dorce 39b9f05cff fix error cannot get 2022-11-03 18:45:28 +01:00
Guillaume Dorce 28c1832001 fix order in express 2022-11-03 17:41:00 +01:00
Guillaume Dorce 9df7b8bc4f fix order in express 2022-11-03 17:38:44 +01:00
Guillaume Dorce 919ac7012a fix order in express 2022-11-03 17:34:35 +01:00
Guillaume Dorce ddfdc29c1f add picture preview for new message 2022-11-03 17:18:57 +01:00
Guillaume Dorce 0b8d60fa46 check user input on signup 2022-11-03 15:44:37 +01:00
Guillaume Dorce eb286154c4 add favicon 2022-11-03 15:44:26 +01:00
Guillaume Dorce 125ce7aa7a add indicator for likes number 2022-11-03 15:24:18 +01:00
Guillaume Dorce b6bda6e436 fix like message 2022-11-03 15:14:48 +01:00
Guillaume Dorce d69f31addc fix a11y 2022-11-03 14:49:59 +01:00
Guillaume Dorce ae743c074d fix error for validator 2022-11-03 14:32:36 +01:00
Guillaume Dorce c927f3aced add user possibility to keep loged in 2022-11-03 12:16:02 +01:00
Guillaume Dorce 83c1b5685a improve user a11y 2022-11-03 12:06:53 +01:00
Guillaume Dorce e420e01e4f handle error for image and add alt atribute 2022-11-03 11:59:50 +01:00
Guillaume Dorce 3e1a2f6b5b delete post picture if exist 2022-11-03 10:08:57 +01:00
Guillaume Dorce f01baf4c2e add edited info on message 2022-11-03 10:05:27 +01:00
Guillaume Dorce 5684e19582 check password to change infos 2022-10-28 17:38:47 +02:00
Guillaume Dorce 9d189bfebd handle info change in modal 2022-10-28 16:46:09 +02:00
Guillaume Dorce d08c0a38fb modal to change personnal info 2022-10-28 12:37:10 +02:00
Guillaume Dorce 37d4ee82be display picture when click on it 2022-10-28 11:59:08 +02:00
Guillaume Dorce 6ac28c7357 really fix scroll bottom with keyframes 2022-10-28 10:46:50 +02:00
Guillaume Dorce 63588e0500 fix express static folder 2022-10-28 10:29:12 +02:00
Guillaume Dorce 85a3231c50 fix instructions on README 2022-10-28 10:29:00 +02:00
Guillaume Dorce bc1f1731d2 fix package.json scripts 2022-10-28 10:21:57 +02:00
Guillaume Dorce b9d2f0a156 fix scrolltobottom button pos 2022-10-28 10:05:21 +02:00
Guillaume Dorce f994e2ca5d update readme instructions 2022-10-28 09:31:22 +02:00
33 changed files with 630 additions and 151 deletions

View File

@ -8,9 +8,29 @@
3. Pour compiler les fichiers, éxecutez `npm run build`.
4. Enfin, pour démarrer le serveur, éxecutez `npm start`.
4. Il faut ensuite générer un fichier `.env` à la racine du projet. Vous pouvez vous aider du fichier `.env.example` pour cela. Pour la base de données, vous pouvez utiliser MySQL ou MariaDB. Si vous désirez utiliser mariadb ou mondodb par exemple, alors il faut aussi modifier le 'provider' dans le fichier `prisma/schema.prisma`.
5. Puis générer la base de données en éxécutant `npm run db:build` et enfin synchroniser les tables avec `npm run db:push`.
6. Enfin, pour démarrer le serveur, éxecutez `npm start`.
7. Pour accéder à l'application, rendez-vous sur `http://localhost:5000`.
## Programme exécutable
1. Téléchargez le programme exécutable pour Windows en cliquant [ici](https://github.com/polynux/groupomania-openclassrooms/releases/download/1.1/groupomania-release-1.1.zip).
2. Décompressez le fichier `groupomania-release-1.1.zip`.
3. Lancez le programme `groupomania.exe`.
Si vous souhaitez compiler le programme exécutable, vous pouvez suivre les instructions suivantes :
Executez `npm run build:neu`.
Le programme exécutable se trouve dans le dossier `groupomania-openclassrooms/client/dist`.
## TODO
- [x] Régler un problème avec prisma. Il demande un roleId pour la création de l'utilisateur mais ce n'est pas présent dans le model.
- [ ] Utiliser NeutralinoJS pour faire une application desktop
- [x] Utiliser NeutralinoJS pour faire une application desktop

1
client/.env-example Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=https://localhost:3000/api

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/icon" href="/favicon.ico" />
<title>Groupomania</title>
</head>
<body class="h-full">

View File

@ -62,6 +62,7 @@
"cli": {
"binaryName": "groupomania",
"resourcesPath": "/dist-vite/",
"clientLibrary": "/public/js/neutralino.js",
"extensionsPath": "/extensions/",
"binaryVersion": "4.7.0",
"clientVersion": "3.6.0"

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

File diff suppressed because one or more lines are too long

View File

@ -25,7 +25,7 @@ const AppHeader = () => {
<header className="sticky top-0 z-100 w-full shadow-sm shadow-slate-900">
<div className="min-h-80 flex items-center justify-between bg-grey-dark p-2">
<div className="app-header__logo flex items-center gap-3">
<img src={logo} alt="logo" className="h-14" />
<img src={logo} alt="Logo du site" className="h-14" />
<span className="w-0 transition-all duration-500 sm:w-full flex-shrink overflow-hidden text-red font-bold text-4xl">Groupomania</span>
</div>
<div className="right">

View File

@ -1,28 +1,27 @@
import gravatar from 'gravatar';
import { useState } from 'react';
import Image from './Image';
const Avatar = ({ user }: any) => {
// console.log(user);
const initials = user.firstName[0] + user.lastName[0];
const gravatarUrl = gravatar.url(user.email, { s: '64', r: 'x', d: '404' }, true);
const avatarUi = `https://ui-avatars.com/api/?name=${initials}&background=0D8ABC&color=fff&size=64`;
// const avatarUi = `https://ui-avatars.com/api/?name=GD&background=0D8ABC&color=fff&size=64`;
const [avatar, setAvatar] = useState(avatarUi);
const [firstLoad, setFirstLoad] = useState(true);
const [error, setError] = useState(false);
if (firstLoad) {
fetch(gravatarUrl).then((response) => {
if (response.status === 200) {
setAvatar(gravatarUrl);
}
setFirstLoad(false);
});
const handleError = (err: any) => {
if (!error) {
setError(true);
}
};
return (
<div className="avatar shrink-0">
<img src={avatar} alt="avatar" className="rounded-full w-12 h-12 md:w-16 md:h-16 transition-all cursor-pointer" />
<Image
src={!error ? gravatarUrl : avatarUi}
alt={`avatar ${user.firstName} ${user.lastName}`}
className="rounded-full w-12 h-12 md:w-16 md:h-16 transition-all cursor-pointer"
onError={handleError}
/>
</div>
);
};

View File

@ -0,0 +1,51 @@
import { useState } from 'react';
import { FaTimes } from 'react-icons/fa';
import Modal from './Modal';
const Image = ({ src, alt, className, onError }: { src: string; alt?: string; className?: string, onError?: (err: any) => void }) => {
const [error, setError] = useState(false);
const [modal, setModal] = useState(false);
const handleError = (err: any) => {
if (!error) {
setError(true);
}
if (onError) {
onError(err);
}
};
const onClick = () => {
setModal(true);
};
return (
<>
{modal && (
<div onClick={() => setModal(false)}>
<Modal show={modal} className="w-auto flex flex-col items-end max-w-[80%]">
<FaTimes
className="fill-grey-light hover:fill-grey-dark hover:bg-grey-light cursor-pointer transition-all text-xl w-10 h-10 p-2.5 rounded-md"
onClick={() => setModal(!modal)}
/>
<img
src={(error && onError === undefined) ? 'https://via.placeholder.com/150' : src}
alt={alt}
onError={handleError}
className={className}
/>
</Modal>
</div>
)}
<img
src={(error && onError === undefined) ? 'https://via.placeholder.com/150' : src}
alt={(error && onError === undefined) ? 'Image absente' : alt}
onError={handleError}
className={className}
onClick={onClick}
/>
</>
);
};
export default Image;

View File

@ -1,15 +1,39 @@
import { FaThumbsUp } from 'react-icons/fa';
import { likePost, unlikePost } from '@controllers/LikeController';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toastError, toastSuccess } from '@controllers/Toasts';
import { useEffect, useState } from 'react';
import { getMeInfo } from '@controllers/UserController';
const Like = ({ messageId, isLiked }: { messageId: string; isLiked: boolean }) => {
const Like = ({ message }: { message: any }) => {
const queryClient = useQueryClient();
const [liked, setLiked] = useState(false);
const me = useQuery(['me'], getMeInfo, {
onSuccess: (data) => {
return data;
},
onError: (error) => {
toastError(error as string);
},
});
const mutateLike = useMutation(isLiked ? unlikePost : likePost, {
useEffect(() => {
if (message.likedBy.some((like: any) => like.userId === me.data?.id)) {
setLiked(true);
}
}, [message]);
const mutateLike = useMutation(liked ? unlikePost : likePost, {
onSuccess: (data) => {
queryClient.invalidateQueries(['messages']);
isLiked ? toastSuccess('Message aimé') : null;
if (data.message === 'Post liked') {
setLiked(true);
toastSuccess('Message liké');
} else if (data.message === 'Post unliked') {
setLiked(false);
} else {
toastError(data.message);
}
},
onError: (error) => {
toastError(error as string);
@ -17,15 +41,22 @@ const Like = ({ messageId, isLiked }: { messageId: string; isLiked: boolean }) =
});
const like = () => {
mutateLike.mutate(messageId);
mutateLike.mutate(message.id);
};
return (
<button
className="absolute -bottom-10 right-0 mb-2 rounded-full bg-grey-dark shadow-lg shadow-slate-900 cursor-pointer"
className="absolute -bottom-10 -right-2 mb-2 rounded-full bg-grey-dark shadow-lg shadow-slate-900 cursor-pointer"
onClick={like}
name="like"
>
<FaThumbsUp className={'fill-red-light text-xl w-10 h-10 p-2.5' + (isLiked ? ' fill-red' : '')} />
<FaThumbsUp className={'fill-red-light text-xl w-10 h-10 p-2.5' + (liked ? ' fill-red' : '')} />
{message.likes > 0 && (
<span className="absolute -top-2 right-0 text-white rounded-full bg-red w-5 h-5 text-xs text-center p-0">
{message.likes}
</span>
)}
<span className="sr-only">Aimer</span>
</button>
);
};

View File

@ -5,18 +5,8 @@ import { getMeInfo } from '@controllers/UserController';
import { useQuery } from '@tanstack/react-query';
import { toastError } from '@controllers/Toasts';
import User from './User';
const Image = ({ image }: { image: string }) => {
if (image === '' || image === null) {
return null;
}
return (
<div className="flex justify-center ">
<img src={image} alt="image" className="w-full rounded-lg cursor-pointer" />
</div>
);
};
import Image from './Image';
import { useState } from 'react';
const Text = ({ text }: { text: string }) => {
if (text === '') {
@ -50,7 +40,8 @@ const Message = ({ message }: any) => {
) : null}
</div>
<Text text={message.content} />
<Image image={message.image} />
{message.image && <Image src={message.image} alt="image" className="w-fit rounded-lg cursor-pointer" />}
<div className="flex justify-between gap-3 flex-wrap">
<div className="text-grey-light date">
{new Date(message.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
@ -60,14 +51,10 @@ const Message = ({ message }: any) => {
minute: 'numeric',
})}
</div>
{me.data?.id === message.author.id ? null : (
<Like
messageId={message.id}
isLiked={
message.likes > 0 && message.likedBy.find((like: any) => like.userId === me.data?.id) ? true : false
}
/>
)}
{message.edited && <div className="text-grey-light italic flex-grow">Modifié</div>}
{me.data?.id === message.author.id && message.likes > 0 && <div className="text-white">{message.likes} likes</div>}
</div>
{me.data?.id !== message.author.id && <Like message={message} />}
</div>
</div>
</>

View File

@ -3,6 +3,7 @@ import { getMessages } from '@controllers/MessageController';
import { useQuery } from '@tanstack/react-query';
import { toastError } from '@controllers/Toasts';
import ScrollToBottom from './ScrollToBottom';
import { api } from '../main';
const MessageWrapper = () => {
const {
@ -10,6 +11,14 @@ const MessageWrapper = () => {
isLoading,
isError,
} = useQuery(['messages'], getMessages, {
onSuccess: (data) => {
data.map((message: any) => {
if (message.image) {
message.image = api.slice(0, -4) + message.image;
}
});
return data;
},
onError: (error) => {
toastError(error as string);
},
@ -21,7 +30,7 @@ const MessageWrapper = () => {
return (
<main className="messages-wrapper rounded-md w-full max-w-3xl flex flex-col flex-shrink relative overflow-y-hidden">
<ScrollToBottom className="message-container flex flex-col gap-4 w-full max-w-3xl overflow-scroll pt-4 pb-6 px-2 md:px-0">
<ScrollToBottom className="message-container flex flex-col gap-4 w-full max-w-3xl overflow-y-scroll overflow-x-hidden pt-4 pb-6 px-2 md:px-0">
{isLoading
? ''
: isError

View File

@ -1,12 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { FaPlus } from 'react-icons/fa';
import {newMessage} from '@controllers/MessageController';
import { useRef, useState } from 'react';
import { FaPlus, FaTimes } from 'react-icons/fa';
import { newMessage } from '@controllers/MessageController';
const NewMessage = () => {
const [message, setMessage] = useState('');
const [image, setImage] = useState('');
const queryClient = useQueryClient();
const [preview, setPreview] = useState('');
const imageRef = useRef<HTMLInputElement>(null);
const handleImageSelect = (e: any) => {
setImage(e.target.value);
const file = e.target.files[0];
if (file) {
const src = URL.createObjectURL(file);
setPreview(src);
}
};
const { mutate: send } = useMutation(newMessage, {
onSuccess: () => {
@ -26,29 +37,65 @@ const NewMessage = () => {
send(data);
setMessage('');
setImage('');
setPreview('');
if (imageRef.current) {
imageRef.current.value = '';
}
};
const handleRemoveImage = () => {
setImage('');
setPreview('');
};
return (
<footer className="new-message z-10 flex justify-center w-full">
<form onSubmit={handleSubmit} className="flex gap-2 bg-grey-dark rounded-xl w-full max-w-3xl p-2 mx-2 sm:p-3 md:mx-0 shadow-md shadow-grey-dark">
<div className="w-full max-w-3xl">
{preview !== '' && (
<div className="image-preview w-fit rounded-xl bg-grey-dark p-3 absolute bottom-20 shadow-md shadow-grey-dark">
<img src={preview} alt="Image" className="rounded-lg max-w-xs max-h-48" />
<span className="rounded-full text-grey-light bg-grey-dark text-lg p-2 block absolute -top-4 -right-4 cursor-pointer shadow-md shadow-slate-800 hover:text-grey-dark hover:bg-grey-light transition-all" onClick={handleRemoveImage}>
<FaTimes className="" />
</span>
</div>
)}
<form
onSubmit={handleSubmit}
className="flex gap-2 bg-grey-dark rounded-xl p-2 mx-2 sm:p-3 md:mx-0 shadow-md shadow-grey-dark"
>
<div className="file">
<label htmlFor="image" className="cursor-pointer block p-2">
<div className="rounded-full text-grey-dark bg-red-light text-lg p-2">
<span className="rounded-full text-grey-dark bg-red-light text-lg p-2 block">
<FaPlus className="" />
</div>
</span>
<span className="sr-only">Ajouter une image</span>
</label>
<input type="file" name="image" id="image" accept="image/*" className="hidden" value={image} onChange={e => setImage(e.target.value)} />
<input
type="file"
name="image"
id="image"
accept="image/*"
className="hidden"
{...(image !== '' && { value: image })}
onChange={handleImageSelect}
ref={imageRef}
/>
</div>
<div className="new-message flex-grow">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Message"
className="bg-grey-dark text-white rounded-xl p-2.5 w-full placeholder-red-light"
id='content'
name='content'
id="content"
name="content"
/>
<label htmlFor="content" className="sr-only">
Message
</label>
</div>
<button
type="submit"
className="rounded-md border border-red bg-red py-2 px-4 text-sm font-medium text-white hover:bg-white hover:text-red focus:outline-none focus:ring-2 focus:ring-red focus:ring-offset-2"
@ -56,6 +103,7 @@ const NewMessage = () => {
Envoyer
</button>
</form>
</div>
</footer>
);
};

View File

@ -1,4 +1,4 @@
import { ReactNode, useCallback, useEffect, useState } from 'react';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { FaChevronDown } from 'react-icons/fa';
const ScrollToBottom = ({ children, className = '' }: { children: ReactNode; className?: string }) => {
@ -12,21 +12,37 @@ const ScrollToBottom = ({ children, className = '' }: { children: ReactNode; cla
}
}, []);
useEffect(() => {
useMemo(() => {
if (node) {
const container = document.querySelector('main > div');
container?.addEventListener('scroll', () => {
if (node?.getBoundingClientRect() && node.getBoundingClientRect().y >= window.innerHeight) {
setShow(true);
} else {
setShow(false);
}
});
container?.addEventListener('DOMNodeInserted', (e) => {
if (!(e.target as Element).classList.contains('message')) return;
if (node?.getBoundingClientRect() && node.getBoundingClientRect().y >= window.innerHeight) {
node?.scrollIntoView({ behavior: 'smooth' });
}
});
container?.querySelectorAll('.message > div > img').forEach((img) => {
img.addEventListener('load', () => {
if (node?.getBoundingClientRect() && node.getBoundingClientRect().y >= window.innerHeight) {
node?.scrollIntoView({ behavior: 'smooth' });
}
});
});
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShow(false);
} else {
setShow(true);
}
},
{ threshold: 1 }
);
observer.observe(node);
return () => observer.disconnect();
}
}, [node]);
return (
@ -37,11 +53,13 @@ const ScrollToBottom = ({ children, className = '' }: { children: ReactNode; cla
</div>
<button
onClick={() => node?.scrollIntoView({ behavior: 'smooth' })}
className={'absolute right-3 transition-all' + (show ? ' bottom-3' : ' -bottom-10')}
className={'absolute right-3 ' + (show ? 'animate-show bottom-3 block' : 'animate-hide hidden bottom-0 -mb-10')}
name="bottom"
>
<div className="popup-btn cursor-pointer rounded-full shadow-lg shadow-slate-900 bg-grey-dark hover:bg-grey-light transition-all">
<span className="popup-btn block cursor-pointer rounded-full shadow-lg shadow-slate-900 bg-grey-dark hover:bg-grey-light transition-all">
<FaChevronDown className="fill-grey-light hover:fill-grey-dark transition-all text-xl w-10 h-10 p-2.5" />
</div>
</span>
<span className="sr-only">Descendre en bas de la page</span>
</button>
</>
);

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import { FormEvent, useState } from 'react';
import Modal from './Modal';
import { getMeInfo, giveUserRights } from '@controllers/UserController';
import { getMeInfo, giveUserRights, changeUserInfo } from '@controllers/UserController';
import { toastError, toastSuccess } from '@controllers/Toasts';
import { useQuery, useQueryClient } from '@tanstack/react-query';
@ -20,9 +20,8 @@ const User = ({ author }: any) => {
const queryClient = useQueryClient();
function handleContextMenu(e: any) {
if (messageId!== '0' && e.target.closest('.message')?.id.slice(9) !== messageId) {
setPopupPos({ posX: 0, posY: 0});
if (messageId !== '0' && e.target.closest('.message')?.id.slice(9) !== messageId) {
setPopupPos({ posX: 0, posY: 0 });
setMessageId('0');
document.removeEventListener('contextmenu', handleContextMenu);
}
@ -56,6 +55,22 @@ const User = ({ author }: any) => {
queryClient.invalidateQueries(['messages']);
}
const handleChangeInfo = (e: FormEvent) => {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
changeUserInfo(author.id, data).then((response) => {
if (response.error) {
return toastError(response.error);
}
toastSuccess('Infos personelles changées');
queryClient.invalidateQueries(['messages']);
setShow(false);
}).catch((error) => {
toastError(error.error);
});
};
return (
<div className="user">
<button
@ -90,6 +105,7 @@ const User = ({ author }: any) => {
)}
<Modal show={show}>
<div className="flex flex-col gap-5">
{me.data?.id !== author.id ? (
<div className="flex flex-col gap-2">
<div className="text-2xl text-white">Info utilisateur</div>
<div className="text-white">Nom: {author.lastName}</div>
@ -97,7 +113,6 @@ const User = ({ author }: any) => {
<div className="text-white">
Adresse mail: <a href={'mailto:' + author.email}>{author.email}</a>
</div>
</div>
<button
className="rounded-md border border-red bg-red py-2 px-4 text-sm max-w-[100px] font-medium text-white hover:bg-white hover:text-red focus:outline-none focus:ring-2 focus:ring-red focus:ring-offset-2"
onClick={() => setShow(false)}
@ -105,6 +120,83 @@ const User = ({ author }: any) => {
Fermer
</button>
</div>
) : (
<>
<div className="text-2xl text-white">Modifier ses infos personnelles</div>
<div className="text-white">
{' '}
Tous les champs avec <span className="text-red">*</span> sont obligatoires
</div>
<form className="flex flex-col gap-2" onSubmit={handleChangeInfo}>
<label htmlFor="firstName" className="text-white">
Prenom <span className="text-red">*</span>
</label>
<input
type="text"
name="firstName"
id="firstName"
className="rounded-lg p-2"
defaultValue={author.firstName}
required
/>
<label htmlFor="lastName" className="text-white">
Nom <span className="text-red">*</span>
</label>
<input
type="text"
name="lastName"
id="lastName"
className="rounded-lg p-2"
defaultValue={author.lastName}
required
/>
<label htmlFor="email" className="text-white">
Adresse mail (ne sera pas modifié)
</label>
<input
type="email"
name="email"
id="email"
className="rounded-lg p-2"
defaultValue={author.email}
required
disabled
/>
<label htmlFor="password" className="text-white">
Mot de passe <span className="text-red">*</span>
</label>
<input type="password" name="password" id="password" className="rounded-lg p-2" required />
<div>
<div className="text-white text-xl">Changer son mot de passe:</div>
<div className="flex flex-col gap-2">
<label htmlFor="newPassword" className="text-white">
Nouveau mot de passe
</label>
<input type="password" name="newPassword" id="newPassword" className="rounded-lg p-2" />
<label htmlFor="confirmPassword" className="text-white">
Confirmer le nouveau mot de passe
</label>
<input type="password" name="confirmPassword" id="confirmPassword" className="rounded-lg p-2" />
</div>
</div>
<div className="flex gap-4">
<div
className="rounded-md border py-2 px-4 text-sm max-w-[100px] font-medium text-white hover:bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 bg-transparent hover:text-red cursor-pointer"
onClick={(e) => setShow(false)}
>
Annuler
</div>
<button
type="submit"
className="rounded-md border border-red bg-red py-2 px-4 text-sm max-w-[100px] font-medium text-white hover:bg-white hover:text-red focus:outline-none focus:ring-2 focus:ring-red focus:ring-offset-2"
>
Modifier
</button>
</div>
</form>
</>
)}
</div>
</Modal>
</div>
);

View File

@ -1,8 +1,9 @@
import { Cookies } from "react-cookie";
import { api } from "../main";
const likePost = async (id: string) => {
const token = new Cookies().get('token');
const response = await fetch(`/api/posts/like/${id}`, {
const response = await fetch(`${api}/posts/like/${id}`, {
method: 'PUT',
mode: 'cors',
headers: {
@ -19,7 +20,7 @@ const likePost = async (id: string) => {
const unlikePost = async (id: string) => {
const token = new Cookies().get('token');
const response = await fetch(`/api/posts/unlike/${id}`, {
const response = await fetch(`${api}/posts/unlike/${id}`, {
method: 'PUT',
mode: 'cors',
headers: {

View File

@ -1,8 +1,9 @@
import { Cookies } from 'react-cookie';
import { api } from '../main';
const getMessages = async () => {
const token = new Cookies().get('token');
const response = await fetch('/api/posts', {
const response = await fetch(api + '/posts', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
@ -17,7 +18,7 @@ const getMessages = async () => {
const newMessage = async (data: FormData) => {
const token = new Cookies().get('token');
const response = await fetch('/api/posts/new', {
const response = await fetch(api + '/posts/new', {
method: 'POST',
body: data,
mode: 'cors',
@ -30,7 +31,7 @@ const newMessage = async (data: FormData) => {
const deleteMessage = async (id: string) => {
const token = new Cookies().get('token');
const response = await fetch(`/api/posts/delete/${id}`, {
const response = await fetch(`${api}/posts/delete/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
@ -41,7 +42,7 @@ const deleteMessage = async (id: string) => {
const editMessage = async (id: string, data: FormData) => {
const token = new Cookies().get('token');
const response = await fetch(`/api/posts/edit/${id}`, {
const response = await fetch(`${api}/posts/edit/${id}`, {
method: 'PUT',
body: data,
mode: 'cors',

View File

@ -1,9 +1,10 @@
import { Cookies } from 'react-cookie';
import { api } from '../main';
const getMeInfo = async () => {
const token = new Cookies().get('token');
const response = await fetch('/api/me', {
const response = await fetch(api + '/me', {
method: 'GET',
mode: 'cors',
headers: {
@ -21,7 +22,7 @@ const getMeInfo = async () => {
const login = async ({ email, password }: { email: string; password: string }) => {
const token = new Cookies().get('token');
const response = await fetch('/api/auth/login', {
const response = await fetch(api + '/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
mode: 'cors',
@ -51,7 +52,7 @@ const signup = async (formData: FormData) => {
throw 'Passwords do not match';
}
const response = await fetch('/api/auth/signup', {
const response = await fetch(api + '/auth/signup', {
method: 'POST',
body: JSON.stringify(form),
mode: 'cors',
@ -69,7 +70,7 @@ const signup = async (formData: FormData) => {
export const giveUserRights = async (userId: string, role: string) => {
const token = new Cookies().get('token');
const response = await fetch(`/api/users/${userId}/roles`, {
const response = await fetch(`${api}/users/${userId}/roles`, {
method: 'POST',
mode: 'cors',
headers: {
@ -88,4 +89,55 @@ export const giveUserRights = async (userId: string, role: string) => {
return data;
};
export const changeUserInfo = async (userId: string, formData: FormData) => {
const token = new Cookies().get('token');
const firstName = formData.get('firstName');
const lastName = formData.get('lastName');
const password = formData.get('password');
if (!firstName || !lastName || !password) {
throw {error: 'Les champs ne peuvent pas être vides'};
}
const newPassword = formData.get('newPassword');
const confirmPassword = formData.get('confirmPassword');
if (newPassword) {
if (newPassword === password) {
throw {error: 'Le nouveau mot de passe doit être différent de l\'ancien'};
}
// regex to check if password is strong enough
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
if (!regex.test(newPassword as string)) {
throw {error: 'Le mot de passe doit contenir au moins 8 caractères, une majuscule, une minuscule, un chiffre et un caractère spécial'};
}
if (newPassword !== confirmPassword) {
throw {error: 'Les mots de passe ne correspondent pas'};
}
}
const response = await fetch(`${api}/users/${userId}`, {
method: 'PUT',
mode: 'cors',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
firstName,
lastName,
password,
newPassword,
}),
});
const data = await response.json();
if (data.error) {
return {error: data.error};
}
return data;
};
export { getMeInfo, login, signup };

View File

@ -3,3 +3,10 @@ import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<App />);
let api = '/api';
if (import.meta.env.VITE_API_URL) {
api = import.meta.env.VITE_API_URL;
}
export { api };

View File

@ -5,7 +5,7 @@ import 'react-toastify/dist/ReactToastify.css';
const Home = () => {
return (
<div className="min-h-full max-h-full bg-grey flex flex-col items-center overflow-y-hidden">
<div className="min-h-full max-h-full bg-grey flex flex-col items-center justify-between overflow-y-hidden">
<AppHeader />
<MessageWrapper />
<NewMessage />

View File

@ -11,14 +11,15 @@ const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [cookies, setCookie] = useCookies(['token']);
const [keepMeLoggedIn, setKeepMeLoggedIn] = useState(false);
const mutation = useMutation(login, {
onSuccess: (data: Token) => {
setCookie('token', data.token, { path: '/', expires: new Date(data.expiresAt) });
setCookie('token', data.token, { path: '/', expires: keepMeLoggedIn ? new Date(data.expiresAt) : undefined });
},
onError: (error) => {
toastError(error as string);
}
},
});
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@ -68,6 +69,19 @@ const Login = () => {
/>
</div>
</div>
<div className="flex items-center mb-2">
<input
id="remember_me"
name="remember_me"
type="checkbox"
className="h-4 w-4 text-red focus:ring-red border-gray-300 rounded"
checked={keepMeLoggedIn}
onChange={(e) => setKeepMeLoggedIn(e.target.checked)}
/>
<label htmlFor="remember_me" className="ml-2 block text-sm text-white">
Se souvenir de moi
</label>
</div>
<div>
<button
type="submit"

View File

@ -2,15 +2,38 @@ import { Token } from '@/types';
import logo from '@assets/images/logo.svg';
import { toastError, toastSuccess } from '@controllers/Toasts';
import { signup } from '@controllers/UserController';
import { useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
export default () => {
const navigate = useNavigate();
const checkPasswordComplexity = (password: string) => {
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\da-zA-Z]).{8,}$/;
return regex.test(password);
};
const checkEmailValidity = (email: string) => {
const regex = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/;
return regex.test(email);
};
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
const email = data.get('email') as string;
const password = data.get('password') as string;
const passwordConfirmation = data.get('password-confirm') as string;
const firstName = data.get('firstName') as string;
const lastName = data.get('lastName') as string;
if (!/^[a-zA-Z]+$/.test(firstName)) return toastError('Le prénom ne doit contenir que des lettres.');
if (!/^[a-zA-Z]+$/.test(lastName)) return toastError('Le nom ne doit contenir que des lettres.');
if (!checkEmailValidity(email)) return toastError('L\'adresse e-mail n\'est pas valide.');
if (password !== passwordConfirmation) return toastError('Les mots de passe ne correspondent pas.');
if (!checkPasswordComplexity(password)) return toastError('Le mot de passe doit contenir au moins 8 caractères, une majuscule, une minuscule, un chiffre et un caractère spécial.');
signup(data).then((data: Token) => {
toastSuccess('You have successfully signed up!');
toastSuccess('Vous êtes inscrit !');
navigate('/login');
}).catch((err) => {
toastError(err as string);
@ -23,8 +46,8 @@ export default () => {
<div>
<img className="mx-auto h-20 pb-2 w-auto" src={logo} alt="Groupomania" />
</div>
<div className="w-full max-w-md space-y-8 bg-grey rounded-lg p-5">
<form className="m-6 " action="#" method="POST" onSubmit={(e) => onSubmit(e)}>
<div className="w-full max-w-md space-y-4 bg-grey rounded-lg p-5">
<form className="m-6 mb-0" action="#" method="POST" onSubmit={(e) => onSubmit(e)}>
<div className="-space-y-px rounded-md shadow-sm">
<div>
<label htmlFor="lastName" className="sr-only">
@ -106,6 +129,11 @@ export default () => {
</button>
</div>
</form>
<p className="text-center text-sm text-grey-dark">
<Link to="/login" className="font-medium text-red-light hover:text-red">
Retournez à la page de connexion
</Link>
</p>
</div>
</div>
</>

View File

@ -19,6 +19,22 @@ module.exports = {
padding: {
'2.5': '0.625rem',
},
keyframes: {
'hide': {
'0%': { opacity: 1, display: 'block' },
'99%': { opacity: 0, display: 'block' },
'100%': { opacity: 0, display: 'none' },
},
'show': {
'0%': { opacity: 0, display: 'none' },
'1%': { opacity: 0, display: 'block' },
'100%': { opacity: 1, display: 'block' },
},
},
animation: {
'hide': 'hide 0.2s ease-in-out',
'show': 'show 0.2s ease-in-out',
},
},
},
plugins: [],

View File

@ -5,10 +5,15 @@
"main": "index.js",
"scripts": {
"dev": "nodemon --watch src -e js,ts,json --exec 'ts-node src/index.ts'",
"build": "cd client && pnpm build",
"start": "ts-node src/index.ts",
"build": "npm run build:server && npm run build:client",
"build:neu": "cd client && npm run build:neu",
"build:server": "tsc && tsc-alias",
"build:client": "cd client && npm run build",
"start": "node dist/index.js",
"db": "prisma studio",
"db-build": "prisma generate; prisma db push"
"db:build": "prisma generate",
"db:push": "prisma db push",
"postinstall": "cd client && npm install"
},
"keywords": [],
"author": "",

View File

@ -37,6 +37,7 @@ model Post {
updatedAt DateTime @updatedAt
likes Int @default(0)
likedBy Like[]
edited Boolean @default(false)
}
model Like {

View File

@ -10,8 +10,6 @@ export default async (req: Request, res: Response) => {
}
return res.status(200).send({ message: 'Post unliked' });
} catch (error) {
console.log(error);
return res.status(500).send(error);
}
};

View File

@ -1,6 +1,7 @@
import Roles from "./roles";
import { NextFunction, Request, Response, Router } from 'express';
import { verifyToken } from "@/controller/AuthController";
import { changeUserInfo } from "@/controller/UserController";
const users = Router();
@ -28,4 +29,26 @@ users.use(checkAuth);
users.post('/:id/roles', Roles);
users.put('/:id', (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
const token = getToken(req);
if (token === undefined) {
return res.status(401).send({ error: 'No token provided' });
}
if (req.userId !== userId) {
return res.status(401).send({ error: 'Unauthorized' });
}
return changeUserInfo(userId, req.body)
.then((data) => {
if (data instanceof Error) {
return res.status(400).send({ error: data.message });
}
return res.status(200).send(data);
})
.catch((error) => {
return res.status(400).send({ error: error.message });
});
});
export default users;

View File

@ -1,6 +1,7 @@
import multer from 'multer';
import path from 'path';
import {v4 as uuidv4} from 'uuid';
import fs from 'fs';
const storage = multer.diskStorage({
destination: path.join(__dirname, '../../public/uploads'),
@ -10,3 +11,12 @@ const storage = multer.diskStorage({
});
export const upload = multer({ storage });
export const deleteFile = (filename: string) => {
const filePath = path.join(__dirname, '../../public/uploads', filename);
fs.unlink(filePath, (err) => {
if (err) {
console.log(err);
}
});
}

View File

@ -2,6 +2,7 @@ import { PrismaClient, Post as PrismaPost, Like } from '@prisma/client';
import { Post } from '@/models/PostModel';
import { exclude } from '@/lib/utils';
import { getUserById } from './UserController';
import { deleteFile } from './FileController';
const prisma = new PrismaClient();
@ -66,6 +67,7 @@ const editPost = async (post: Post): Promise<PrismaPost | null | Error> => {
data: {
content: post.content,
image: post.image,
edited: true,
},
});
@ -94,6 +96,9 @@ const deletePost = async (id: number, userId: number): Promise<PrismaPost | Erro
if (post.authorId !== userId && user.role === 'USER') {
return new Error('User is not the author of this post');
}
if (post.image) {
deleteFile(post.image);
}
return prisma.post.delete({
where: {
id,
@ -185,11 +190,6 @@ const likePost = async (id: number, userId: number): Promise<PrismaPost | Error>
id,
},
data: {
likedBy: {
connect: {
id: newLike.id,
},
},
likes: {
increment: 1,
},

View File

@ -1,6 +1,7 @@
import { PrismaClient, Role } from '@prisma/client';
import { User } from '@/models/UserModel';
import { exclude } from '@/lib/utils';
import { comparePassword } from './AuthController';
const prisma = new PrismaClient();
@ -96,4 +97,57 @@ export const changeUserRoles = async (id: number, role: Role) => {
return exclude(updatedUser, 'password');
};
export const changeUserInfo = async (
id: number,
userInfo: { firstName: string; lastName: string; password: string; newPassword?: string; confirmPassword: string }
) => {
const currentUser = await prisma.user.findUnique({
where: {
id,
},
});
if (!currentUser) {
return new Error('User not found');
}
const isPasswordCorrect = await comparePassword(userInfo.password, currentUser.password);
if (!isPasswordCorrect) {
return new Error('Password is incorrect');
}
if (userInfo.newPassword) {
if (userInfo.newPassword !== userInfo.confirmPassword) {
return new Error('New password and confirm password do not match');
}
const isPasswordSame = await comparePassword(userInfo.password, currentUser.password);
if (isPasswordSame) {
return new Error('Password are the same');
}
if (userInfo.newPassword !== userInfo.confirmPassword) {
return new Error('New password and confirm password are not the same');
}
}
const data = {
firstName: userInfo.firstName,
lastName: userInfo.lastName,
};
if (userInfo.newPassword) Object.setPrototypeOf(data, { password: userInfo.newPassword });
const updatedUser = await prisma.user.update({
where: {
id,
},
data,
});
if (!updatedUser) {
return new Error('User not found');
}
return exclude(updatedUser, 'password');
};
export { getUser, newUser, isUserExist, getUserById };

View File

@ -5,6 +5,7 @@ import { config as envConfig } from 'dotenv';
import { deleteExpiredTokens } from '@/controller/AuthController';
import ms from 'ms';
import path from 'path';
import fs from 'fs';
envConfig();
@ -42,10 +43,17 @@ app.use(json({ limit: '50mb' }));
app.use(urlencoded({ extended: true, limit: '50mb' }));
app.use(express.static(path.join(__dirname, '../public')));
app.use(express.static(path.join(__dirname, '../client/dist')));
app.use('/api', api);
const staticDist = fs.existsSync(path.join(__dirname, '../client/dist-vite')) ? express.static(path.join(__dirname, '../client/dist-vite')) : express.static(path.join(__dirname, '../client/dist'));
app.use(staticDist);
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../client/dist/index.html'));
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});

View File

@ -8,6 +8,7 @@ interface Post {
authorId: number;
likes?: number | undefined;
likedBy?: Like[] | undefined;
edited?: boolean | undefined;
}
const Post: z.ZodType<Post> = z.object({
@ -17,6 +18,7 @@ const Post: z.ZodType<Post> = z.object({
authorId: z.number(),
likes: z.number().optional(),
likedBy: z.array(Like).optional(),
edited: z.boolean().optional(),
});
export { Post };

View File

@ -1,15 +1,15 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "es2017",
"target": "ES2017",
"lib": [
"esnext"
],
"moduleResolution": "node",
"module": "CommonJS",
"esModuleInterop": true,
"strict": true,
"strictNullChecks": true,
"resolveJsonModule": true,
"skipDefaultLibCheck": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,