diff --git a/api/package.json b/api/package.json index 80fd560..25fddd2 100644 --- a/api/package.json +++ b/api/package.json @@ -5,7 +5,7 @@ "author": "soulsam480 ", "private": true, "scripts": { - "dev": "ts-node-dev --respawn --files -- src/index.ts", + "dev": "ts-node-dev --respawn --transpile-only --ignore-watch node_modules --files -- src/index.ts", "build": "rm -rf dist && yarn && tsc && tsc-alias", "build:swagger": "node swagger.js", "build:types": "tsc --declaration --emitDeclarationOnly --declarationDir ./types && tsc-alias", @@ -24,8 +24,7 @@ "@types/swagger-jsdoc": "^6.0.1", "@types/swagger-ui-express": "^4.1.3", "ts-node-dev": "^1.1.8", - "tsconfig-paths": "^3.10.1", - "typescript": "4.3.5" + "tsconfig-paths": "^3.10.1" }, "dependencies": { "@typegoose/typegoose": "^8.2.0", @@ -46,7 +45,6 @@ }, "workspaces": { "nohoist": [ - "**/typescript/**/**", "**/mongoose/**" ] } diff --git a/api/src/controllers/comments.ts b/api/src/controllers/comments.ts new file mode 100644 index 0000000..2706080 --- /dev/null +++ b/api/src/controllers/comments.ts @@ -0,0 +1,148 @@ +import { createRoute } from 'dango-core'; +import { DocumentDefinition } from 'mongoose'; + +import { + createCommentOnPost, + createReplyOnPost, + getCommentsForPost, + getRepliesForComment, + likeOnComment, + likeOnReply, + unLikeOnComment, + unLikeOnReply, +} from 'src/services/comments'; +import { formatResponse } from 'src/utils/helpers'; +import { Comment } from 'src/entities/post'; + +export const getCommentsByPostId = createRoute< + any, + { id: string }, + { cursor: string; limit: string } +>({ + path: '/:id/comments', + method: 'get', + handler: async (_, res, __, { id }, { cursor: pagination_cursor, limit: pagination_limit }) => { + try { + const cursor: number = parseInt(pagination_cursor); + const limit: number = parseInt(pagination_limit) || 10; + + const comments = await getCommentsForPost(id, cursor, limit); + + res.json(comments); + } catch (error) { + console.log(error); + res.sendError(500, error); + } + }, +}); + +export const addCommentByPostId = createRoute< + { comment: DocumentDefinition }, + { id: string } +>({ + path: '/:id/comments', + method: 'post', + handler: async ({ userId }, res, { comment }, { id }) => { + try { + await createCommentOnPost(id, { ...comment, user: userId as string }); + + res.sendStatus(200); + } catch (error) { + console.log(error); + res.sendError(500, error); + } + }, +}); + +export const getRepliesByCommentId = createRoute({ + path: '/:id/comments/:commentId', + method: 'get', + handler: async (_, res, __, { id, commentId }) => { + try { + const replies = await getRepliesForComment(id, commentId); + + res.json(formatResponse(replies)); + } catch (error) { + console.log(error); + res.sendError(500, error); + } + }, +}); + +export const addReplyByCommentId = createRoute< + { comment: DocumentDefinition }, + { id: string; commentId: string } +>({ + path: '/:id/comments/:commentId', + method: 'post', + handler: async ({ userId }, res, { comment }, { id, commentId }) => { + try { + await createReplyOnPost(id, commentId, { ...comment, user: userId as string }); + + res.sendStatus(200); + } catch (error) { + console.log(error); + res.sendError(500, error); + } + }, +}); + +export const likeComment = createRoute({ + path: '/:id/comments/:commentId/like', + method: 'post', + handler: async ({ userId }, res, _, { id, commentId }) => { + try { + await likeOnComment(id, commentId, userId as string); + + res.sendStatus(200); + } catch (error) { + console.log(error); + res.sendError(500, error); + } + }, +}); + +export const unLikeComment = createRoute({ + path: '/:id/comments/:commentId/unlike', + method: 'post', + handler: async ({ userId }, res, _, { id, commentId }) => { + try { + await unLikeOnComment(id, commentId, userId as string); + + res.sendStatus(200); + } catch (error) { + console.log(error); + res.sendError(500, error); + } + }, +}); + +export const likeReply = createRoute({ + path: '/:id/comments/:commentId/replies/:replyId/like', + method: 'post', + handler: async ({ userId }, res, _, { commentId, id, replyId }) => { + try { + await likeOnReply(id, commentId, replyId, userId as string); + + res.sendStatus(200); + } catch (error) { + console.log(error); + res.sendError(500, error); + } + }, +}); + +export const unLikeReply = createRoute({ + path: '/:id/comments/:commentId/replies/:replyId/unlike', + method: 'post', + handler: async ({ userId }, res, _, { commentId, id, replyId }) => { + try { + await unLikeOnReply(id, commentId, replyId, userId as string); + + res.sendStatus(200); + } catch (error) { + console.log(error); + res.sendError(500, error); + } + }, +}); diff --git a/api/src/controllers/post.ts b/api/src/controllers/post.ts index 065685b..22c4f4f 100644 --- a/api/src/controllers/post.ts +++ b/api/src/controllers/post.ts @@ -1,7 +1,7 @@ import { createController, createRoute } from 'dango-core'; -import { DocumentDefinition, UpdateQuery } from 'mongoose'; +import { UpdateQuery } from 'mongoose'; import { createError, formatResponse } from 'src/utils/helpers'; -import { Comment, Post } from 'src/entities/post'; +import { Post } from 'src/entities/post'; import { createPost, deletePostById, @@ -13,11 +13,15 @@ import { updatePostById, } from 'src/services/post'; import { - createCommentOnPost, - createReplyOnPost, - getCommentsForPost, - getRepliesForComment, -} from 'src/services/comments'; + getCommentsByPostId, + addCommentByPostId, + getRepliesByCommentId, + addReplyByCommentId, + likeComment, + unLikeComment, + likeReply, + unLikeReply, +} from 'src/controllers/comments'; /** * @private only for seeding once @@ -171,69 +175,6 @@ const deleteById = createRoute({ }, }); -const getCommentsByPostId = createRoute({ - path: '/:id/comments', - method: 'get', - handler: async (_, res, __, { id }) => { - try { - const comments = await getCommentsForPost(id); - - res.json(formatResponse(comments)); - } catch (error) { - console.log(error); - res.sendError(500, error); - } - }, -}); - -const addCommentByPostId = createRoute<{ comment: DocumentDefinition }, { id: string }>({ - path: '/:id/comments', - method: 'post', - handler: async ({ userId }, res, { comment }, { id }) => { - try { - await createCommentOnPost(id, { ...comment, user: userId as string }); - - res.sendStatus(200); - } catch (error) { - console.log(error); - res.sendError(500, error); - } - }, -}); - -const getRepliesByCommentId = createRoute({ - path: '/:id/comments/:commentId', - method: 'get', - handler: async (_, res, __, { id, commentId }) => { - try { - const replies = await getRepliesForComment(id, commentId); - - res.json(formatResponse(replies)); - } catch (error) { - console.log(error); - res.sendError(500, error); - } - }, -}); - -const addReplyByCommentId = createRoute< - { reply: DocumentDefinition }, - { id: string; commentId: string } ->({ - path: '/:id/comments/:commentId', - method: 'post', - handler: async ({ userId }, res, { reply }, { id, commentId }) => { - try { - await createReplyOnPost(id, commentId, { ...reply, user: userId as string }); - - res.sendStatus(200); - } catch (error) { - console.log(error); - res.sendError(500, error); - } - }, -}); - const postController = createController('/posts', [ getPosts, postsByUserId, @@ -247,6 +188,10 @@ const postController = createController('/posts', [ addCommentByPostId, getRepliesByCommentId, addReplyByCommentId, + likeComment, + unLikeComment, + likeReply, + unLikeReply, ]); export { postController }; diff --git a/api/src/entities/post.ts b/api/src/entities/post.ts index 6ec249d..ce7b510 100644 --- a/api/src/entities/post.ts +++ b/api/src/entities/post.ts @@ -9,8 +9,8 @@ import { User } from 'src/entities/user'; }, }) export class Comment extends TimeStamps { - @prop() - public comment: string; + @prop({ ref: 'Post' }) + post_id: Ref; @prop({ ref: 'User' }) public user: Ref; @@ -18,21 +18,41 @@ export class Comment extends TimeStamps { @prop({ ref: 'User' }) public likes?: Ref[]; - @prop({ type: () => Reply, default: [] }) - public replies?: Reply[]; + @prop({ ref: 'Reply' }) + public replies?: Ref[]; + + @prop() + public comment: string; public get total_likes() { return this.likes?.length; } - public get total_replies() { - return this.likes?.length; - } + @prop({ ref: () => Reply, foreignField: 'comment_id', localField: '_id', count: true }) + public total_replies?: number; + + // @prop({ + // ref: () => User, + // foreignField: 'liked_comments', + // localField: '_id', + // count: true, + // match: (doc) => ({ _id: doc._id }), + // }) + // public total_likes?: number; } -class Reply extends TimeStamps { - @prop() - public comment: string; +@modelOptions({ + schemaOptions: { + timestamps: true, + toJSON: { virtuals: true, versionKey: false }, + }, +}) +export class Reply extends TimeStamps { + @prop({ ref: 'Comment' }) + public comment_id: Ref; + + @prop({ ref: 'Post' }) + public post_id: Ref; @prop({ ref: 'User' }) public user: Ref; @@ -40,11 +60,10 @@ class Reply extends TimeStamps { @prop({ ref: 'User' }) public likes?: Ref[]; - public get total_likes() { - return this.likes?.length; - } + @prop() + public comment: string; - public get total_replies() { + public get total_likes() { return this.likes?.length; } } @@ -68,8 +87,8 @@ export class Post extends TimeStamps { @prop({ ref: 'User' }) public likes?: Ref[]; - @prop({ type: () => Comment }) - public comments?: Comment[]; + @prop({ ref: 'Comment' }) + public comments?: Ref[]; @prop({ default: false }) is_archived?: boolean; @@ -86,3 +105,5 @@ export class Post extends TimeStamps { export const postModel = getModelForClass(Post); export const commentModel = getModelForClass(Comment); + +export const replyModel = getModelForClass(Reply); diff --git a/api/src/entities/user.ts b/api/src/entities/user.ts index 4aa8186..3b3fff6 100644 --- a/api/src/entities/user.ts +++ b/api/src/entities/user.ts @@ -1,7 +1,7 @@ import { getModelForClass, modelOptions, pre, prop, Ref } from '@typegoose/typegoose'; import { compare, hash } from 'bcrypt'; import { parseEnv } from 'src/utils/helpers'; -import { Comment, Post } from 'src/entities/post'; +import { Comment, Post, Reply } from 'src/entities/post'; import { TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; @modelOptions({ @@ -9,6 +9,7 @@ import { TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; }) @pre('save', async function (next) { if (!this.isModified('password')) next(); + try { this.password = await this.hashPassword(this.password); next(); @@ -61,6 +62,12 @@ export class User extends TimeStamps { @prop({ ref: () => Comment }) public liked_comments?: Ref[]; + @prop({ ref: () => Reply }) + public replied_posts?: Ref[]; + + @prop({ ref: () => Reply }) + public liked_replies?: Ref[]; + public async hashPassword(pass: string) { if (!pass) return ''; try { diff --git a/api/src/services/comments.ts b/api/src/services/comments.ts index 90fa418..d7c9a3b 100644 --- a/api/src/services/comments.ts +++ b/api/src/services/comments.ts @@ -1,16 +1,28 @@ import { DocumentDefinition } from 'mongoose'; -import { Comment, commentModel, postModel } from 'src/entities/post'; -import { getObjectId } from 'src/utils/helpers'; +import { Comment, commentModel, postModel, replyModel } from 'src/entities/post'; +import { User, userModel } from 'src/entities/user'; +import { cursorPaginateResponse, getObjectId } from 'src/utils/helpers'; -export async function getCommentsForPost(id: string) { +//TODO paginate +export async function getCommentsForPost(id: string, cursor: number, limit: number) { try { - const post = await postModel - .findOne({ _id: id }) - .populate({ path: 'comments', model: Comment }) - .select(['comments']) - .exec(); + const baseQuery = commentModel + .find({ post_id: id }) + .populate({ + path: 'user', + model: User, + select: ['name', 'username', 'id', 'image'], + }) + .populate('total_replies') + .select('-replies') + .sort({ createdAt: -1 }); - return post?.comments; + return await cursorPaginateResponse( + baseQuery, + cursor, + limit, + await baseQuery.estimatedDocumentCount(), + ); } catch (error) { Promise.reject(error); } @@ -19,33 +31,78 @@ export async function getCommentsForPost(id: string) { //TODO: add update and delete export async function createCommentOnPost(id: string, comment: DocumentDefinition) { try { - const newComment = await commentModel.create({ ...comment }); + const newComment = await commentModel.create({ ...comment, post_id: id }); await postModel .updateOne( { _id: id }, { - $push: { comments: newComment }, + $push: { comments: getObjectId(newComment._id) }, }, ) .exec(); + + await userModel + .updateOne( + { _id: getObjectId(comment.user as string) }, + { $push: { commented_posts: getObjectId(id) } }, + ) + .exec(); } catch (error) { Promise.reject(error); } } -export async function getRepliesForComment(id: string, commentId: string) { +export async function likeOnComment(id: string, commentId: string, userId: string) { try { - const post = await postModel - .findOne({ - _id: id, - comments: { $elemMatch: { _id: getObjectId(commentId) } }, - }) - .populate({ path: 'comments.replies' }) - .select('comments') + await commentModel + .updateOne( + { _id: commentId, post_id: getObjectId(id) }, + { + $push: { likes: getObjectId(userId) }, + }, + ) + .exec(); + + await userModel + .updateOne({ _id: userId }, { $push: { liked_comments: getObjectId(commentId) } }) + .exec(); + } catch (error) { + Promise.reject(error); + } +} + +export async function unLikeOnComment(id: string, commentId: string, userId: string) { + try { + await commentModel + .updateOne( + { _id: commentId, post_id: getObjectId(id) }, + { + $pull: { likes: getObjectId(userId) }, + }, + ) + .exec(); + + await userModel + .updateOne({ _id: userId }, { $pull: { liked_comments: getObjectId(commentId) } }) .exec(); + } catch (error) { + Promise.reject(error); + } +} - return post?.comments; +//TODO paginate +export async function getRepliesForComment(post_id: string, comment_id: string) { + try { + return await replyModel + .find({ post_id, comment_id }) + .sort({ createdAt: -1 }) + .populate({ + path: 'user', + model: User, + select: ['name', 'username', 'id', 'image'], + }) + .exec(); } catch (error) { Promise.reject(error); } @@ -53,28 +110,76 @@ export async function getRepliesForComment(id: string, commentId: string) { //TODO: add update and delete export async function createReplyOnPost( - id: string, - commentId: string, + post_id: string, + comment_id: string, reply: DocumentDefinition, ) { try { - const newReply = await commentModel.create({ ...reply }); + const newReply = await replyModel.create({ ...reply, comment_id, post_id }); - await postModel.updateOne( - { _id: id }, - { - $push: { - 'comments.$[el].replies': newReply, + await commentModel + .updateOne( + { _id: comment_id, post_id }, + { + $push: { replies: getObjectId(newReply._id) }, }, - }, - { - arrayFilters: [ - { - 'el._id': getObjectId(commentId), - }, - ], - }, - ); + ) + .exec(); + + await userModel + .updateOne( + { _id: getObjectId(reply.user as string) }, + { $push: { replied_comments: getObjectId(comment_id) } }, + ) + .exec(); + } catch (error) { + Promise.reject(error); + } +} + +export async function likeOnReply( + post_id: string, + comment_id: string, + replyId: string, + userId: string, +) { + try { + await replyModel + .updateOne( + { _id: replyId, post_id, comment_id }, + { + $push: { likes: getObjectId(userId) }, + }, + ) + .exec(); + + await userModel + .updateOne({ _id: userId }, { $push: { liked_replies: getObjectId(replyId) } }) + .exec(); + } catch (error) { + Promise.reject(error); + } +} + +export async function unLikeOnReply( + post_id: string, + comment_id: string, + replyId: string, + userId: string, +) { + try { + await replyModel + .updateOne( + { _id: replyId, post_id, comment_id }, + { + $pull: { likes: getObjectId(userId) }, + }, + ) + .exec(); + + await userModel + .updateOne({ _id: userId }, { $pull: { liked_replies: getObjectId(replyId) } }) + .exec(); } catch (error) { Promise.reject(error); } diff --git a/api/src/utils/helpers.ts b/api/src/utils/helpers.ts index 126c431..11a1929 100644 --- a/api/src/utils/helpers.ts +++ b/api/src/utils/helpers.ts @@ -122,7 +122,7 @@ export async function cursorPaginateResponse( //@ts-ignore data = await model .find({ - updatedAt: { + createdAt: { $lt: new Date(decrypedDate), }, }) @@ -140,7 +140,7 @@ export async function cursorPaginateResponse( if (has_more) { const nextCursorRecord = data[limit]; - var unixTimestamp = Math.floor(nextCursorRecord.updatedAt!.getTime() / 1000); + var unixTimestamp = Math.floor(nextCursorRecord.createdAt!.getTime() / 1000); next_cursor = unixTimestamp.toString(); data.pop(); } diff --git a/api/tsconfig.json b/api/tsconfig.json index 99fd190..62240ba 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -4,7 +4,7 @@ /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ - "target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, + "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, // "lib": [], /* Specify library files to be included in the compilation. */ "allowJs": true /* Allow javascript files to be compiled. */, @@ -68,9 +68,8 @@ /* Advanced Options */ "skipLibCheck": true /* Skip type checking of declaration files. */, - "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, + // "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, "resolveJsonModule": true }, - "exclude": ["node_modules"], "include": ["./src/**/*.ts", "./src/custom.d.ts"] } diff --git a/app/index.html b/app/index.html index ca4ee4e..1bc1ff9 100644 --- a/app/index.html +++ b/app/index.html @@ -30,6 +30,7 @@
+
\ No newline at end of file diff --git a/app/package.json b/app/package.json index 71e6688..c1609d3 100644 --- a/app/package.json +++ b/app/package.json @@ -30,7 +30,6 @@ "@types/uuid": "^8.3.1", "@vitejs/plugin-react-refresh": "^1.3.6", "sass": "^1.39.0", - "typescript": "^4.4.2", "vite": "^2.5.3", "vite-plugin-purge-icons": "^0.7.0", "vite-plugin-pwa": "^0.11.2", @@ -39,10 +38,5 @@ }, "syncIgnore": [ "react-router-dom" - ], - "workspaces": { - "nohoist": [ - "**/typescript/**/**" - ] - } + ] } diff --git a/app/src/App.tsx b/app/src/App.tsx index 84eab25..257fb4c 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -4,15 +4,34 @@ import { useAuth, useAuthRedirect } from 'src/utils/auth'; import { useJuneRouter } from 'src/Shared/router'; import * as JAlert from 'src/Lib/JAlerts'; import FloatingLoader from 'src/Shared/components/FloatingLoader'; +// import { Dialog, DialogAPI } from './Lib/context/dialog'; interface Props {} const App: React.FC = () => { const { Routes } = useJuneRouter(); const { isLoading, auth } = useAuth(); - const { isLoading: isCatchLoading } = useAuthRedirect(); + // const Dialogs = useRef([]); + + // function addToDialog(dialog: Dialog) { + // const isDialog = Dialogs.current.findIndex((el) => el.id === dialog.id); + + // if (isDialog === -1) { + // //TODO: improve this + // if (!!Dialogs.current.length) Dialogs.current = []; + // Dialogs.current.push({ ...dialog }); + // } + // } + + // function removeFromDialog(id: string) { + // const isDialog = Dialogs.current.findIndex((el) => el.id === id); + // if (isDialog !== -1) { + // Dialogs.current.splice(isDialog, 1); + // } + // } + useEffect(() => { (async () => await auth())(); }, []); @@ -21,12 +40,14 @@ const App: React.FC = () => { <> {!isLoading && !isCatchLoading && (
+ {/* */} }>
{Routes}
{' '} + {/*
*/}
)} diff --git a/app/src/Feed/components/PostCard.tsx b/app/src/Feed/components/PostCard.tsx index cde5a72..420f218 100644 --- a/app/src/Feed/components/PostCard.tsx +++ b/app/src/Feed/components/PostCard.tsx @@ -14,9 +14,10 @@ import { classNames } from 'src/utils/helpers'; interface Props extends JCardProps { post: Post; updatePostReaction: (post: Post) => void; + onCommentClick?: () => void; } -const PostCard: React.FC = ({ post, updatePostReaction, ...rest }) => { +const PostCard: React.FC = ({ post, updatePostReaction, onCommentClick, ...rest }) => { const id = useUserStore((state) => state.user.id); const { pathname } = useLocation(); const navigate = useNavigate(); @@ -32,6 +33,15 @@ const PostCard: React.FC = ({ post, updatePostReaction, ...rest }) => { navigate(`/${post.user.username}/post/${post.id}`); } + + function handleCommentClick() { + if (!!onCommentClick) { + onCommentClick(); + return; + } + + navigate(`/${post.user.username}/post/${post.id}?comments=true`); + } return ( = ({ post, updatePostReaction, ...rest }) => { {...rest} headerSlot={
- +
- + {' '}
-
+
{post.user.username}
{post.user.name}
@@ -58,7 +73,13 @@ const PostCard: React.FC = ({ post, updatePostReaction, ...rest }) => {
- +
diff --git a/app/src/Feed/components/comments/PostComment.tsx b/app/src/Feed/components/comments/PostComment.tsx new file mode 100644 index 0000000..9e137f1 --- /dev/null +++ b/app/src/Feed/components/comments/PostComment.tsx @@ -0,0 +1,251 @@ +import React, { HTMLProps, memo, useMemo } from 'react'; +import JAvatar from 'src/Lib/JAvatar'; +import JButton from 'src/Lib/JButton'; +import JIcon from 'src/Lib/JIcon'; +import { classNames, timeAgo } from 'src/utils/helpers'; +import { Comment, Reply } from 'src/utils/types'; +import { useComment } from 'src/Feed/context/commentApi'; +import { likeComment, likeReply, unLikeComment, unLikeReply } from 'src/Shared/services/post'; +import { useUserStore } from 'src/User/store/useUserStore'; +import { useAlert } from 'src/Lib/store/alerts'; + +export interface ReplyCtx { + user: string; + id: string; + username: string; +} +interface Props extends HTMLProps { + comment: Comment; +} + +const PostComment: React.FC = ({ className, comment }) => { + const { onRepliesExpand, setReplyCtx, postId, updateCommentReaction } = useComment(); + + const userId = useUserStore((s) => s.user.id); + + const setAlert = useAlert((state) => state.setAlert); + + function localUnlike() { + updateCommentReaction({ + id: comment.id, + likes: comment?.likes.filter((el) => el !== userId), + } as Comment); + } + + function localLike() { + updateCommentReaction({ id: comment.id, likes: [...comment?.likes, userId] } as Comment); + } + + async function handleReaction(comment: Comment) { + if (comment.likes.includes(userId)) { + localUnlike(); + + try { + await unLikeComment(postId, comment.id); + } catch (error) { + setAlert({ type: 'danger', message: 'Some error occured !' }); + console.log(error); + + localLike(); + } + } else { + localLike(); + + try { + await likeComment(postId, comment.id); + } catch (error) { + setAlert({ type: 'danger', message: 'Some error occured !' }); + console.log(error); + + localUnlike(); + } + } + } + + const getTimeAgo = useMemo(() => timeAgo, []); + + return ( +
+
+
+ +
+
+
+ + {comment?.user?.username} + {' '} +   + {comment?.comment} +
+
+ {getTimeAgo(comment?.createdAt as Date)} {' '} + + {comment.total_likes} {comment.total_likes > 1 ? 'likes' : 'like'}{' '} + + + setReplyCtx({ + id: comment.id, + user: comment.user.id, + username: comment.user.username, + }) + } + title={`reply to ${comment.user.username}`} + /> +
+ {!comment.replies && comment.total_replies ? ( +
+ 1 ? 'replies' : 'reply' + }`} + noBg + dense + sm + onClick={() => onRepliesExpand(comment.id)} + /> +
+ ) : null} +
+
+ handleReaction(comment)} + iconSlot={ + <> + + + + + + + + } + /> +
+
+
+
+ {comment.replies?.map((reply) => { + return ; + })} +
+
+
+ ); +}; + +export const PostReply: React.FC<{ + reply: Reply; + commentId: string; +}> = ({ reply, commentId }) => { + const { updateReplyReaction, postId, onRepliesExpand } = useComment(); + + const getTimeAgo = useMemo(() => timeAgo, []); + + const userId = useUserStore((s) => s.user.id); + + const setAlert = useAlert((state) => state.setAlert); + + function localUnlike() { + updateReplyReaction(commentId, { + id: reply.id, + likes: reply?.likes.filter((el) => el !== userId), + } as Reply); + } + + function localLike() { + updateReplyReaction(commentId, { id: reply.id, likes: [...reply?.likes, userId] } as Reply); + } + + async function handleReaction(reply: Reply) { + if (reply.likes.includes(userId)) { + localUnlike(); + + try { + await unLikeReply(postId, commentId, reply.id); + + onRepliesExpand(commentId); + } catch (error) { + setAlert({ type: 'danger', message: 'Some error occured !' }); + console.log(error); + + localLike(); + } + } else { + localLike(); + + try { + await likeReply(postId, commentId, reply.id); + + onRepliesExpand(commentId); + } catch (error) { + setAlert({ type: 'danger', message: 'Some error occured !' }); + console.log(error); + + localUnlike(); + } + } + } + + return ( +
+
+ +
+
+
+ + {reply?.user?.username} + {' '} +   + {reply?.comment} +
+
+ {getTimeAgo(reply?.createdAt as Date)} {' '} + {reply?.total_likes} likes +
+
+
+ handleReaction(reply)} + iconSlot={ + <> + + + + + + + + } + /> +
+
+ ); +}; + +const MemoizedPostComment = memo(PostReply); + +export default PostComment; diff --git a/app/src/Feed/components/comments/PostCommentForm.tsx b/app/src/Feed/components/comments/PostCommentForm.tsx new file mode 100644 index 0000000..1569d45 --- /dev/null +++ b/app/src/Feed/components/comments/PostCommentForm.tsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; +import { useComment } from 'src/Feed/context/commentApi'; +import JButton from 'src/Lib/JButton'; +import JInput from 'src/Lib/JInput'; + +interface Props {} + +const PostCommentForm: React.FC = () => { + const [comment, setComment] = useState(''); + + const { commentAction } = useComment(); + + async function handleSubmission(e: React.FormEvent) { + e.preventDefault(); + await commentAction(comment); + + setComment(''); + } + + return ( +
+ +
+ +
+ + ); +}; + +export default PostCommentForm; diff --git a/app/src/Feed/components/comments/PostCommentsContainer.tsx b/app/src/Feed/components/comments/PostCommentsContainer.tsx new file mode 100644 index 0000000..fe75704 --- /dev/null +++ b/app/src/Feed/components/comments/PostCommentsContainer.tsx @@ -0,0 +1,191 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { CommentApi } from 'src/Feed/context/commentApi'; +import JButton from 'src/Lib/JButton'; +import JContainer from 'src/Lib/JContainer'; +import PostComment, { ReplyCtx } from 'src/Feed/components/comments/PostComment'; +import PostCommentForm from 'src/Feed/components/comments/PostCommentForm'; +import { Comment, PaginationParams, Reply } from 'src/utils/types'; +import { useAlert } from 'src/Lib/store/alerts'; +import { + createCommentOnPost, + createReplyOnComment, + getCommentReplies, + getPostComments, +} from 'src/Shared/services/post'; +import { useObserver, usePaginatedQuery } from 'src/utils/hooks'; + +const MemoizedPostComment = React.memo(PostComment); + +interface Props { + postId: string; +} + +const PostCommentsContainer: React.FC = ({ postId }) => { + const setAlert = useAlert((s) => s.setAlert); + const [observerRef] = useObserver(observerCb); + + const { + data: postComments, + isLoading: isCommentsLoading, + forceValidate: setPostComments, + validate: getComments, + reset, + isEnd, + } = usePaginatedQuery([], fetcher(postId), { limit: 15 }); + + async function observerCb() { + if (isEnd) return; + if (isCommentsLoading) return; + await getComments(); + } + + const [replyCtx, setReplyCtx] = useState(null); + + const updateCommentReaction = useCallback((comment: Comment) => { + setPostComments((p) => + p.map((el) => (el.id !== comment.id ? el : { ...el, likes: comment.likes })), + ); + }, []); + + // function localAddComment() { + + // } + + const updateReplyReaction = useCallback((commentId: string, reply: Reply) => { + setPostComments((p) => + p.map((co) => + co.id !== commentId + ? co + : { + ...co, + replies: co.replies?.map((re) => + re.id !== reply.id ? re : { ...re, likes: reply.likes }, + ), + }, + ), + ); + }, []); + + function fetcher(id: string) { + return (opts: PaginationParams) => getPostComments(id, { ...opts }); + } + + async function getRepliesOnComment(id: string) { + try { + const { + data: { data }, + } = await getCommentReplies(postId, id); + + setPostComments((p) => p.map((el) => (el.id !== id ? el : { ...el, replies: data }))); + } catch (error) { + console.log(error); + setAlert({ type: 'danger', message: (error as any).message }); + } + } + + async function createComment(comment: string) { + if (isCommentsLoading) return; + + try { + await createCommentOnPost(postId, { comment }); + + reset(); + getComments(); + } catch (error) { + console.log(error); + setAlert({ type: 'danger', message: (error as any).message }); + } + } + + async function createReply(commentId: string, comment: string) { + if (isCommentsLoading) return; + + try { + await createReplyOnComment(postId, commentId, { comment }); + + reset(); + getComments(); + } catch (error) { + console.log(error); + setAlert({ type: 'danger', message: (error as any).message }); + } finally { + setReplyCtx(null); + } + } + + async function commentAction(comment: string) { + if (!comment) return; + + if (replyCtx) { + return await createReply(replyCtx.id, comment); + } + + await createComment(comment); + } + + useEffect(() => { + getComments(); + }, []); + + return ( + + + {!!replyCtx && ( +
+
+ replying to {replyCtx?.username}{' '} +
+ setReplyCtx(null)} + /> +
+ )} + + +
+ + {!!postComments.length ? ( + + {postComments.map((comment) => { + return ; + })} + + ) : ( + !isCommentsLoading && ( +
no comments yet !
+ ) + )} + + {(!!postComments.length || isCommentsLoading) && ( + <> +
+ {/* {isCommentsLoading && Array.from(Array(2)).map((_, i) => )}{' '} */} +
+ +
+
+ + )} +
+ ); +}; + +export default PostCommentsContainer; diff --git a/app/src/Feed/components/postCard/PostReact.tsx b/app/src/Feed/components/postCard/PostReact.tsx index 4586c9e..9049e89 100644 --- a/app/src/Feed/components/postCard/PostReact.tsx +++ b/app/src/Feed/components/postCard/PostReact.tsx @@ -4,6 +4,7 @@ import { likePost, unlikePost } from 'src/Shared/services/post'; import { classNames } from 'src/utils/helpers'; import { Post } from 'src/utils/types'; import { useAlert } from 'src/Lib/store/alerts'; +import JIcon from 'src/Lib/JIcon'; interface Props { updatePostReaction(post: Post): void; uid: string; @@ -50,11 +51,20 @@ const PostReact: React.FC = ({ updatePostReaction, uid, post }) => { return ( handleReaction(post)} - className={classNames([{ 'fill-current text-red-700': post.likes.includes(uid) }])} + iconSlot={ + <> + + + + + + + + } /> ); }; diff --git a/app/src/Feed/context/commentApi.ts b/app/src/Feed/context/commentApi.ts new file mode 100644 index 0000000..aaa6727 --- /dev/null +++ b/app/src/Feed/context/commentApi.ts @@ -0,0 +1,25 @@ +import { createContext, useContext } from 'react'; +import { ReplyCtx } from 'src/Feed/components/comments/PostComment'; +import { Comment, Reply } from 'src/utils/types'; + +export interface CommentAPI { + setReplyCtx: (ctx: ReplyCtx | null) => void; + onRepliesExpand: (id: string) => any; + commentAction: (comment: string) => Promise; + postId: string; + updateCommentReaction(comment: Comment): void; + updateReplyReaction(commentId: string, reply: Reply): void; +} + +export const CommentApi = createContext({ + onRepliesExpand: null as any, + setReplyCtx: null as any, + commentAction: null as any, + postId: null as any, + updateCommentReaction: null as any, + updateReplyReaction: null as any, +}); + +export function useComment() { + return useContext(CommentApi); +} diff --git a/app/src/Feed/pages/PostDetail.tsx b/app/src/Feed/pages/PostDetail.tsx index 6162bad..b1703d8 100644 --- a/app/src/Feed/pages/PostDetail.tsx +++ b/app/src/Feed/pages/PostDetail.tsx @@ -1,22 +1,26 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useLocation, useParams } from 'react-router-dom'; import JSpinner from 'src/Lib/JSpinner'; -import { createCommentOnPost, getPost, getPostComments } from 'src/Shared/services/post'; +import { getPost } from 'src/Shared/services/post'; import { useMountedRef } from 'src/utils/hooks'; import { Post } from 'src/utils/types'; import PostCard from 'src/Feed/components/PostCard'; -import JContainer from 'src/Lib/JContainer'; -import JButton from 'src/Lib/JButton'; -import JInput from 'src/Lib/JInput'; +import { useAlert } from 'src/Lib/store/alerts'; +import PostCommentsContainer from 'src/Feed/components/comments/PostCommentsContainer'; interface Props {} const PostDetail: React.FC = () => { const { postId } = useParams(); + const { search } = useLocation(); + const setAlert = useAlert((s) => s.setAlert); + const [postData, setPostData] = useState({} as any); const [isLoading, setLoading] = useState(false); - const [comment, setComment] = useState(''); + const [isRenders, setIsRenders] = useState({ + isComments: false, + }); const { mountedRef } = useMountedRef(); @@ -36,50 +40,41 @@ const PostDetail: React.FC = () => { setPostData({ ...data }); } catch (error) { console.log(error); + + setAlert({ type: 'danger', message: (error as any).message }); } finally { setLoading(false); } } - async function getComments() { - try { - const { data } = await getPostComments(postId); - console.log(data); - } catch (error) { - console.log(error); - } - } + function handleCommentBtnClick() { + if (isRenders.isComments) return; - async function createComment() { - if (!comment) return; - - try { - await createCommentOnPost(postId, { comment }); - } catch (error) { - console.log(error); - } + setIsRenders((p) => ({ ...p, isComments: true })); } useEffect(() => { getPostDetails(); }, [postId]); + useEffect(() => { + if (!search) return; + + const isComments = new URLSearchParams(search).get('comments'); + if (!isComments) return; + + setIsRenders((p) => ({ ...p, isComments: true })); + }, [search]); + return !isLoading && !!Object.keys(postData).length ? (
- - -
- - -
- - -
+ + + {isRenders.isComments && }
) : (
diff --git a/app/src/Lib/JButton.tsx b/app/src/Lib/JButton.tsx index dfb8248..1838a44 100644 --- a/app/src/Lib/JButton.tsx +++ b/app/src/Lib/JButton.tsx @@ -80,7 +80,7 @@ const JButton: React.FC = ({ <> {!!iconSlot ? ( - iconSlot + {iconSlot} ) : icon ? ( ) : ( diff --git a/app/src/Lib/JDialog.tsx b/app/src/Lib/JDialog.tsx index 34f6749..1ad54c3 100644 --- a/app/src/Lib/JDialog.tsx +++ b/app/src/Lib/JDialog.tsx @@ -1,7 +1,9 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { CSSTransition } from 'react-transition-group'; +import { randomId } from 'src/utils/helpers'; import { useClickoutside } from 'src/utils/hooks'; +// import { DialogAPI } from 'src/Lib/context/dialog'; interface Props { isModal: boolean; @@ -10,6 +12,9 @@ interface Props { const JDialog: React.FC = ({ isModal, setModal, children }) => { const [ref] = useClickoutside(() => setModal(false)); + const myID = useRef(randomId(10)); + + // const { addToDialog, removeFromDialog } = useContext(DialogAPI); const keyListenersMap = new Map([ ['Escape', handleEscape], @@ -59,25 +64,34 @@ const JDialog: React.FC = ({ isModal, setModal, children }) => { !isModal && document.body.classList.remove('body-noscroll'); }, [isModal]); + // useEffect(() => { + // isModal && addToDialog({ id: myID.current, close: () => setModal(false) }); + // !isModal && removeFromDialog(myID.current); + // }, [isModal]); + return createPortal( -
+ , - document.body, + document.getElementById('dialog-root') as HTMLDivElement, ); }; diff --git a/app/src/Lib/JInput.tsx b/app/src/Lib/JInput.tsx index e2792e7..0d4dcc2 100644 --- a/app/src/Lib/JInput.tsx +++ b/app/src/Lib/JInput.tsx @@ -1,50 +1,72 @@ -import React, { CSSProperties } from 'react'; +import React, { HTMLProps, useMemo } from 'react'; import { classNames } from 'src/utils/helpers'; +type InputElement = HTMLInputElement | HTMLTextAreaElement; interface Props { onInput: (e: string) => void; - value?: string | number | readonly string[]; - type?: string; className?: string; - id?: string; - placeholder?: string; is?: 'input' | 'textarea'; contentClass?: string; label?: string; + dense?: boolean; + onEnter?: (e: React.KeyboardEvent) => void; } -const JInput: React.FC = ({ - value, +const JInput: React.FC, 'onInput'>> = ({ onInput, - type, className, - id, - placeholder, is = 'input', contentClass, label, + dense, + onEnter, + onKeyDown, + ...rest }) => { + const inputClasses = useMemo( + () => [ + { + 'j-input--dense': dense, + }, + className || '', + 'j-input', + ], + [dense], + ); + + function handleKeyDown(e: React.KeyboardEvent) { + if (!!onKeyDown) { + onKeyDown(e); + } + + if (!!onEnter) { + if (e.key !== 'Enter') return; + onEnter(e); + } + } + return ( -
- {label &&