Merge branch 'dev' into search

This commit is contained in:
Ceferino Patino 2022-08-18 16:50:31 -04:00 committed by GitHub
commit 4384db3df0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 316 additions and 110 deletions

View file

@ -16,6 +16,7 @@
"js-cookie": "^3.0.1",
"marked": "^4.0.18",
"react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0",
"react-gravatar": "^2.6.3",
"react-helmet": "^6.1.0",
"react-markdown": "^8.0.3",

View file

@ -3,11 +3,23 @@ import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from '
import ReactTimeAgo from 'react-time-ago';
import { Markdown } from 'components';
import { readMail } from 'services/mailServices';
export default function MailUnit({ sender, createdAt, subject, text }) {
export default function MailUnit({
mail_id,
sender,
createdAt,
subject,
text,
read,
}) {
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
onClick={() => {readMail(mail_id)}}
sx={read && {backgroundColor: 'lightGray'}}
>
<Box style={{ width: '100%' }} display={'flex'}>
<Typography>From {sender}</Typography>
<span style={{ width: '1vw' }} />
@ -16,6 +28,8 @@ export default function MailUnit({ sender, createdAt, subject, text }) {
<Typography>
<b>{subject}</b>
</Typography>
<span style={{ width: '2vw' }} />
<Typography>Read: {String(read)}</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>

View file

@ -2,15 +2,17 @@ import { IconButton, Typography } from '@mui/material';
import Gravatar from 'react-gravatar';
import { Link } from 'react-router-dom';
export default function Profile({ userData: { email, level, points } }) {
export default function Profile({ userData: { badgeCount, email, level, points } }) {
return (
<>
<IconButton component={Link} to='/dashboard'>
<Gravatar email={email} size={20} style={{ borderRadius: '100%' }} />
</IconButton>
<Typography variant='body1'>
<b>{level}</b> - {points}
</Typography>
<Typography display='inline'><b>{level}</b></Typography>
<Typography display='inline' m={1}>{points}</Typography>
<Typography color='#ffd700' display='inline'>{badgeCount?.gold}</Typography>
/<Typography color='#c0c0c0' display='inline'>{badgeCount?.silver}</Typography>
/<Typography color='#cd7f32' display='inline'>{badgeCount?.bronze}</Typography>
</>
);
}

View file

@ -1,4 +1,5 @@
import { Divider, ListItem, ListItemText } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import { Divider, IconButton, ListItem, ListItemText } from '@mui/material';
import { CreationInfoTag } from 'controllers';
import { VoteControl } from 'controllers/QAControllers';
@ -11,6 +12,7 @@ export default function AnswerComment({
text,
upvotes,
getVote,
onDelete,
updateVote,
}) {
return (
@ -21,6 +23,9 @@ export default function AnswerComment({
{text}
</ListItemText>
<VoteControl {...{ downvotes, getVote, updateVote, upvotes }} />
{onDelete && <IconButton onClick={onDelete}>
<DeleteIcon />
</IconButton>}
</ListItem>
<Divider />
</span>

View file

@ -1,4 +1,5 @@
import { Divider, ListItem, ListItemText } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import { Divider, IconButton, ListItem, ListItemText } from '@mui/material';
import { CreationInfoTag } from 'controllers';
import { VoteControl } from 'controllers/QAControllers';
@ -11,7 +12,8 @@ export default function Comment({
text,
upvotes,
getVote,
updateVote,
onDelete,
updateVote
}) {
return (
<span key={comment_id}>
@ -21,6 +23,9 @@ export default function Comment({
{text}
</ListItemText>
<VoteControl {...{ downvotes, getVote, updateVote, upvotes, creator }} />
{onDelete && <IconButton onClick={onDelete}>
<DeleteIcon />
</IconButton>}
</ListItem>
<Divider />
</span>

View file

@ -0,0 +1,67 @@
import {Tooltip, Button, Card, CardContent, Typography} from '@mui/material'
import { editQuestion } from 'services/questionsServices';
import EditFormController from "controllers/FormControllers/EditQuestionFormController";
import { useEffect, useState } from "react";
import { useUser, useQuestion } from 'contexts';
import { Markdown } from 'components';
export default function Suggest(){
const [show, setShow] = useState(false);
const [canSuggest, setCanSuggest] = useState(false)
const {userData} = useUser();
const {questionData} = useQuestion();
const [ongoingEdit, setOngoingEdit] = useState({users : [], new : []})
useEffect(()=>{
setCanSuggest(userData.level >= 7)
if(ongoingEdit.users.length > 0)setCanSuggest((userData.username !== ongoingEdit.users[0]))
if(!questionData.loading) setOngoingEdit({users : questionData.edit, new : questionData.editText})
},[userData, questionData, setCanSuggest])
async function toggleShow(){
if(questionData.edit.length === 0){
setShow(!show)
}else{
const { status } = await editQuestion(questionData.question_id);
}
}
return (
<div>
<Tooltip
title= {
canSuggest
? ''
: 'You need to be level 7'}
>
<span>
<Button disabled={!canSuggest} variant='contained' onClick={toggleShow}>
Add Suggestion
</Button>
</span>
</Tooltip>
{show && (
<Card>
<CardContent>
<EditFormController />
</CardContent>
</Card>
)}
{ongoingEdit.users.length > 0 &&
<Card>
<CardContent>
<Markdown {...{content: ongoingEdit.new[0]}}/>
<Markdown {...{content: ongoingEdit.new[1]}}/>
</CardContent>
<Typography> {ongoingEdit.users} have voted to edit the question </Typography>
</Card>
}
</div>
);
}

View file

@ -1,4 +1,5 @@
import { List, ListItem } from '@mui/material';
import Suggest from 'components/QAComponents/Suggest';
import { AnswersList, CommentsList, CreateAnswer, Question } from 'controllers/QAControllers';
export default function QA() {
@ -8,7 +9,8 @@ export default function QA() {
<ListItem sx={{ pl: 4 }}>
<CommentsList />
</ListItem>
<CreateAnswer />
<CreateAnswer/>
<Suggest/>
<div id='answersList' />
<AnswersList />
</List>

View file

@ -11,9 +11,6 @@ export default function UserProvider({ children }) {
const [userData, setUserData] = useState(initialUserData)
const navigate = useNavigate();
useEffect(() => {
const loadUserData = async () => {
const { user, error } = await remember();

View file

@ -0,0 +1,28 @@
import { Form } from 'controllers/FormControllers';
import { generateEditFields } from 'services/fields';
import { editQuestion } from 'services/questionsServices';
import { questionSchema } from 'services/schemas';
import { useQuestion } from 'contexts';
export default function EditFormController() {
const {questionData} = useQuestion();
const {title, text} = questionData;
const editTheQuestion = async (fields) => {
console.log("hello")
const { status } = await editQuestion(questionData.question_id, fields);
window.location.reload(false);
};
const field = generateEditFields
return !questionData.loading && Form({
fields: field,
onSubmit: editTheQuestion,
validationSchema: questionSchema,
initialValues: {etext : text, etitle : title}
});
}

View file

@ -1,6 +1,6 @@
import { useFormik } from 'formik';
import { useEffect } from 'react';
import { useQuestion } from 'contexts';
import { Form } from 'components';
import { useForm } from 'contexts';
@ -10,11 +10,15 @@ export default function FormController({
validate,
validationSchema,
children,
initialValues
}) {
const { setContent } = useForm();
const { setContent } = useForm();
useEffect(() => {
setContent('');
}, [setContent]);
const handleChange = (e) => {
@ -22,7 +26,7 @@ export default function FormController({
};
const formik = useFormik({
initialValues: fields.reduce((acc, { id }) => ({ ...acc, [id]: '' }), {}),
initialValues: fields.reduce((acc, { id }) => ({ ...acc, [id]: initialValues[id] || '' }), {}),
onSubmit,
validate,
validateOnChange: false,

View file

@ -1,9 +1,19 @@
import { useUser } from 'contexts';
import { AnswerComment } from 'components/QAComponents';
import { getAnswerCommentVote, updateAnswerCommentVote } from 'services/questionsServices';
import { getAnswerCommentVote, deleteAnswerComment, updateAnswerCommentVote } from 'services/questionsServices';
export default function AnswerCommentController({ answer_id, comment_id, question_id, ...props }) {
const { userData } = useUser();
const getVote = () => getAnswerCommentVote(question_id, answer_id, comment_id);
const updateVote = (data) => updateAnswerCommentVote(question_id, answer_id, comment_id, data);
const onDelete = async () => {
if (userData?.username === props?.creator) {
const { status } = await deleteAnswerComment(question_id, answer_id, comment_id);
if (status === 200)
window.location.reload(false);
}
}
return <AnswerComment {...{ ...props, getVote, updateVote }} />;
return <AnswerComment {...{ getVote, onDelete, updateVote, ...props }} />;
}

View file

@ -1,9 +1,20 @@
import { useUser } from 'contexts';
import { Comment } from 'components/QAComponents';
import { getCommentVote, updateCommentVote } from 'services/questionsServices';
import { getCommentVote, deleteQuestionComment, updateCommentVote } from 'services/questionsServices';
export default function CommentController({ comment_id, question_id, ...props }) {
const { userData } = useUser();
const getVote = () => getCommentVote(question_id, comment_id);
const updateVote = (data) => updateCommentVote(question_id, comment_id, data);
const onDelete = async () => {
if (userData?.username === props?.creator) {
const { status } = await deleteQuestionComment(question_id, comment_id);
if (status === 200)
window.location.reload(false);
}
}
return <Comment {...{ getVote, updateVote, ...props }} />;
return <Comment {...{ getVote, onDelete, updateVote, ...props }} />;
}

View file

@ -37,8 +37,8 @@ export default function QuestionController() {
useEffect(() => {
const permissions = getPermissions(questionData, userData, ongoingVote);
if(userData.username)canBounty = (userData.level >= 4 && status !== 'closed' && !questionData.hasBounty && !questionData.hasAcceptedAnswer)
if (userData.username) canBounty = (userData.level >= 4 && status !== 'closed' && !questionData.hasBounty && !questionData.hasAcceptedAnswer)
permissions.canBounty = canBounty;
setPermissions(permissions);
setVote();
@ -77,14 +77,14 @@ export default function QuestionController() {
ongoingVoteSet(reopen, 'reopen');
}
}
function handleBounty(amount){
if(show){
const data = {user : userData.username, hasAcceptedAnswer: questionData.hasAcceptedAnswer , amount: amount , isOpen: (questionData.status !== 'closed') , hasBounty: questionData.hasBounty }
function handleBounty(amount) {
if (show) {
const data = { user: userData.username, hasAcceptedAnswer: questionData.hasAcceptedAnswer, amount: amount, isOpen: (questionData.status !== 'closed'), hasBounty: questionData.hasBounty }
addBounty(question_id, data)
questionData.hasBounty = amount;
userData.points -= amount;
setShow(false)
}else{
} else {
setShow(true)
}
}
@ -111,7 +111,7 @@ export default function QuestionController() {
postComment,
show,
handleBounty
}}
/>
)}

View file

@ -131,6 +131,22 @@ const searchFields = [
{ id: 'tags', label: 'Tags (separated by spaces)' },
];
const generateEditFields =
[
{
id: 'etitle',
label: 'Title',
},
{
id: 'etext',
label: 'Text',
multiline: true,
},
]
export {
answerFields,
askQuestionFields,
@ -142,4 +158,5 @@ export {
recoverFields,
resetFields,
searchFields,
generateEditFields
};

View file

@ -1,8 +1,11 @@
{
"Level 1": 1,
"Level 2": 15,
"Level 3": 50,
"Level 4": 125,
"Level 5": 1000,
"Level 6": 3000,
"Level 7": 10000
"Level 4": 75,
"Level 5": 125,
"Level 6": 1000,
"Level 7": 2000,
"Level 8" : 3000,
"Level 9" : 10000
}

View file

@ -2,8 +2,11 @@ import { createEndpoint } from './api';
const callMailAPI = createEndpoint('/mail');
const postMail = async (data) => callMailAPI('post', ``, data);
const getMail = async () => callMailAPI('get', ``);
export { getMail, postMail };
const postMail = async (data) => callMailAPI('post', ``, data);
const readMail = async (mail_id) => callMailAPI('patch', `/${mail_id}`, { read: 'true' });
export { getMail, postMail, readMail };

View file

@ -11,8 +11,17 @@ const postQuestion = async (data) => callQuestionsAPI('post', ``, data);
const getQuestion = async (question_id) => callQuestionsAPI('get', `/${question_id}`);
const addBounty = async (question_id, data) =>
callQuestionsAPI('patch', `/${question_id}/addBounty`, data);
const addBounty = async (question_id,data) => callQuestionsAPI(
'patch',
`/${question_id}/addBounty`,
data
)
const editQuestion = async (question_id,data) => callQuestionsAPI(
'patch',
`/${question_id}/edit`,
data
)
const updateQuestion = async (question_id, data) =>
callQuestionsAPI('patch', `/${question_id}`, data);
@ -110,4 +119,5 @@ export {
openQuestion,
protectQuestion,
addBounty,
editQuestion
};

View file

@ -6,17 +6,17 @@ import { createEndpoint } from './api';
const callUsersAPI = createEndpoint('/users');
const getLoginAttempts = () => {
let loginAttempts = Number(Cookies.get('loginAttempts')) || 0;
let loginTimeout = Number(Cookies.get('loginTimeout')) - Date.now() || 0;
let loginAttempts = Number(Cookies.get('loginAttempts')) || 0;
let loginTimeout = Number(Cookies.get('loginTimeout')) - Date.now() || 0;
if (loginTimeout < 0) {
Cookies.remove('loginAttempts');
Cookies.remove('loginTimeout');
loginAttempts = 0;
loginTimeout = 0;
}
if (loginTimeout < 0) {
Cookies.remove('loginAttempts');
Cookies.remove('loginTimeout');
loginAttempts = 0;
loginTimeout = 0;
}
return { loginAttempts, loginTimeout };
return { loginAttempts, loginTimeout };
};
const getUser = async (username) => callUsersAPI('get', `/${username}`);
@ -27,28 +27,28 @@ const getUserBadges = async () => callUsersAPI('get', `/badges`);
const getUserQuestions = async () => callUsersAPI('get', `/questions`);
const deleteUser = async () => callUsersAPI('delete',`/delete`)
const deleteUser = async () => callUsersAPI('delete', `/delete`)
const incrementLoginAttempts = () => {
const { loginAttempts } = getLoginAttempts();
Cookies.set('loginAttempts', loginAttempts + 1);
if (loginAttempts >= 2) Cookies.set('loginTimeout', Date.now() + 1000 * 60 * 5);
const { loginAttempts } = getLoginAttempts();
Cookies.set('loginAttempts', loginAttempts + 1);
if (loginAttempts >= 2) Cookies.set('loginTimeout', Date.now() + 1000 * 60 * 5);
};
const login = async ({ username, password }) => {
const encoded = Buffer.from(`${username}:${password}`).toString('base64');
const { token, ...data } = await callUsersAPI(
'post',
`/login`,
{ remember: false },
`basic ${encoded}`
);
if (token) {
Cookies.set('token', token);
Cookies.remove('loginAttempts');
Cookies.remove('loginTimeout');
}
return data;
const encoded = Buffer.from(`${username}:${password}`).toString('base64');
const { token, ...data } = await callUsersAPI(
'post',
`/login`,
{ remember: false },
`basic ${encoded}`
);
if (token) {
Cookies.set('token', token);
Cookies.remove('loginAttempts');
Cookies.remove('loginTimeout');
}
return data;
};
const logout = async () => callUsersAPI('post', `/login`, { remember: false });
@ -58,13 +58,13 @@ const updateUser = async (data) => callUsersAPI('patch', ``, data);
const register = async (data) => callUsersAPI('post', ``, data);
const remember = async () => {
if (Cookies.get('token')) {
const { user, error } = await callUsersAPI('get', `/remember`);
if (user)
return { user };
Cookies.remove('token');
return { error }
}
if (Cookies.get('token')) {
const { user, error } = await callUsersAPI('get', `/remember`);
if (user)
return { user };
Cookies.remove('token');
return { error }
}
}
const requestReset = async (data) => callUsersAPI('post', `/reset`, data);
@ -72,18 +72,18 @@ const requestReset = async (data) => callUsersAPI('post', `/reset`, data);
const resetPassword = async (id, data) => callUsersAPI('post', `/reset/${id}`, data);
export {
getLoginAttempts,
getUser,
getUserAnswers,
getUserBadges,
getUserQuestions,
incrementLoginAttempts,
login,
logout,
register,
remember,
updateUser,
requestReset,
resetPassword,
deleteUser
getLoginAttempts,
getUser,
getUserAnswers,
getUserBadges,
getUserQuestions,
incrementLoginAttempts,
login,
logout,
register,
remember,
updateUser,
requestReset,
resetPassword,
deleteUser
};

2
package-lock.json generated
View file

@ -35,6 +35,7 @@
"js-cookie": "^3.0.1",
"marked": "^4.0.18",
"react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0",
"react-gravatar": "^2.6.3",
"react-helmet": "^6.1.0",
"react-markdown": "^8.0.3",
@ -32177,6 +32178,7 @@
"js-cookie": "^3.0.1",
"marked": "^4.0.18",
"react": "^18.2.0",
"react-copy-to-clipboard": "*",
"react-gravatar": "^2.6.3",
"react-helmet": "^6.1.0",
"react-markdown": "^8.0.3",

View file

@ -43,8 +43,8 @@ mongoose.connection.on(
console.error.bind(console, '[ERROR]: MongoDB connection error - ')
);
//cron.schedule('*/30 * * * * *', refreshQuestions);
//cron.schedule('*/2 * * * *', refreshUsers);
// cron.schedule('*/30 * * * * *', refreshQuestions);
// cron.schedule('*/2 * * * *', refreshUsers);
server.listen(port, console.log(`Listening on port: ${port}`));
server.on('error', onError);

View file

@ -18,9 +18,10 @@ const Answer = mongoose.Schema(
);
Answer.post('findOneAndUpdate', (doc) => {
const badges = calculateAnswerBadges(doc.points);
User.findOneAndUpdate({ username: doc.creator }, { $addToSet: { tags: { $each: badges } } });
if (doc) {
const badges = calculateAnswerBadges(doc.upvotes - doc.downvotes);
User.findOneAndUpdate({ username: doc.creator }, { $addToSet: { tags: { $each: badges } } });
}
});
module.exports = mongoose.model('Answer', Answer);

View file

@ -16,7 +16,7 @@ const Question = mongoose.Schema(
reopen: [String],
close: [String],
edit: [String],
editText: { type: String },
editText: [String],
tags: [String],
question_id: { type: String, required: true },
status: { type: String, required: true, default: 'open' },
@ -24,15 +24,16 @@ const Question = mongoose.Schema(
title: { type: String, required: true },
upvotes: { type: Number, required: true, default: 0 },
views: { type: Number, required: true, default: 0 },
hasBounty: {type: Number, required: false}
hasBounty: { type: Number, required: false },
},
{ timestamps: { createdAt: false, updatedAt: true } }
);
Question.post('findOneAndUpdate', (doc) => {
const badges = calculateQuestionBadges(doc.points);
User.findOneAndUpdate({ username: doc.creator }, { $addToSet: { tags: { $each: badges } } });
if(doc){
const badges = calculateQuestionBadges(doc.upvotes - doc.downvotes);
User.findOneAndUpdate({ username: doc.creator }, { $addToSet: { tags: { $each: badges } } });
}
});
module.exports = mongoose.model('Question', Question);

View file

@ -17,10 +17,8 @@ const User = mongoose.Schema(
User.post('findOneAndUpdate', async (doc) => {
const badges = calculateUserBadges(doc.points);
doc.badges = [...new Set([...doc.badges, ...badges])];
doc.save();
await doc.save();
});
module.exports = mongoose.model('User', User);

View file

@ -7,15 +7,15 @@ async function Get(req, res) {
// Fetch mail if expired
if (Number(lastMailFetch) + config.mailExpires < Date.now()) {
const messages = await getAllMail({ username });
return res.send({ messages });
} else {
await getAllMail({ username })
}
const messages = await Mail.find({ receiver: username }).sort({
createdAt: 'desc',
});
return res.send({ messages });
}
}
module.exports = Get;

View file

@ -7,7 +7,7 @@ const getUserLevel = require('server/utils/getUserLevel');
async function EditQuestionStatusClosed(req, res) {
const { user } = req;
const { question_id } = req.params;
const { text } = req.body;
const { etext, etitle } = req.body;
// Verify user has required level
if (getUserLevel(user.points) < 7) {
@ -20,12 +20,12 @@ async function EditQuestionStatusClosed(req, res) {
// Toggle question vote
if (question.edit.length === 0) {
if (!text) {
if (!etext || !etitle) {
return res.status(400).send(config.errorIncomplete);
}
await Question.findByIdAndUpdate(question_id, {
editText: text,
editText: etext,
$push: { edit: user.username },
});
@ -48,11 +48,13 @@ async function EditQuestionStatusClosed(req, res) {
if (question.edit.length === 2) {
const question = await Question.findByIdAndUpdate(question_id, {
edit: [],
text,
etext,
etitle
});
const { success } = await createRequest('patch', `/questions/${question_id}`, {
text,
etext,
etitle
});
return success

View file

@ -21,7 +21,7 @@ router.post('/', Register);
router.patch('/', tokenAuth, Edit);
router.get('/questions', tokenAuth, Questions);
router.get('/answers', tokenAuth, Answers);
router.get('/badge', tokenAuth, GetBadges);
router.get('/badges', tokenAuth, GetBadges);
router.post('/login', basicAuth, Login);
router.get('/remember', tokenAuth, Remember);
router.delete('/logout', tokenAuth, Logout);

View file

@ -8,9 +8,9 @@ async function GetBadges(req, res) {
const response = badges.reduce(
(acc, badge) => {
if (user.badges.includes(badge.title)) {
acc.obtained.append(badge);
acc.obtained.push(badge);
} else {
acc.unobtained.append(badge);
acc.unobtained.push(badge);
}
return acc;
@ -18,7 +18,7 @@ async function GetBadges(req, res) {
{ obtained: [], unobtained: [] }
);
return res.send(response);
return res.send({ badges: response });
}
module.exports = GetBadges;

View file

@ -55,8 +55,8 @@ async function GetUser(req, res) {
email: newUser.email,
points: newUser.points,
level: getUserLevel(newUser.points),
badgeCount,
},
badgeCount,
});
}

View file

@ -1,15 +1,38 @@
const getUserLevel = require('server/utils/getUserLevel');
const Badge = require('../../db/models/Badge');
async function Remember(req, res) {
const { user } = req;
const badges = await Badge.find({ title: { $in: user.badges } });
const badgeCount = badges.reduce(
(acc, badge) => {
if (badge.rank === 'Gold') {
acc.gold = acc.gold + 1;
} else if (badge.rank === 'Silver') {
acc.silver = acc.silver + 1;
} else if (badge.rank === 'Bronze') {
acc.bronze = acc.bronze + 1;
}
return acc;
},
{
gold: 0,
silver: 0,
bronze: 0,
}
);
return res.send({
user: {
username: user.username,
email: user.email,
points: user.points,
level: getUserLevel(user.points),
}
badgeCount,
},
});
}