diff --git a/apps/blog/app/[param]/[p2]/[permlink]/content.tsx b/apps/blog/app/[param]/[p2]/[permlink]/content.tsx index 89e37f1700746bd19abd7664192edadc835c6099..99e028ba81beed099dd1a14a236e7e951df8e751 100644 --- a/apps/blog/app/[param]/[p2]/[permlink]/content.tsx +++ b/apps/blog/app/[param]/[p2]/[permlink]/content.tsx @@ -110,26 +110,36 @@ const PostContent = () => { enabled: crossedPost }); - const { data: suggestionData } = useQuery({ + const { + data: suggestionData, + error, + isError + } = useQuery({ queryKey: ['suggestions', author, permlink], queryFn: async () => { - const results = await getSimilarPostsByPost({ - author, - permlink, - observer, - result_limit: 10, // Only get 10 suggestions - full_posts: 10 // Get all as full posts - }); + try { + const results = await getSimilarPostsByPost({ + author, + permlink, + observer, + result_limit: 10, + full_posts: 10 + }); - if (!results) return null; + if (!results) return null; - // Filter out null/invalid posts and only include full Entry objects (not stubs) - const fullPosts = results.filter( - (post) => post && !isPostStub(post) && (post as Entry).post_id - ) as Entry[]; - return fullPosts; + const fullPosts = results.filter( + (post) => post && !isPostStub(post) && (post as Entry).post_id + ) as Entry[]; + + return fullPosts; + } catch (err) { + console.error('Error fetching similar posts', err); + throw err; + } } }); + const { data: communityData } = useQuery({ queryKey: ['community', category], queryFn: () => getCommunity(category, observer), diff --git a/apps/blog/features/post-editor/reply-textbox.tsx b/apps/blog/features/post-editor/reply-textbox.tsx index ac646b37f7160610fe2d439147d323009d6e7559..fed3fbaec82cb7a99738ab0bc0d88813754e4278 100644 --- a/apps/blog/features/post-editor/reply-textbox.tsx +++ b/apps/blog/features/post-editor/reply-textbox.tsx @@ -1,3 +1,5 @@ +'use client'; + import Link from 'next/link'; import { Button } from '@ui/components/button'; import { useEffect, useState, useRef } from 'react'; @@ -14,7 +16,6 @@ import { getLogger } from '@ui/lib/logging'; import { useCommentMutation, useUpdateCommentMutation } from '../post-rendering/hooks/use-comment-mutations'; import { handleError } from '@ui/lib/handle-error'; import { CircleSpinner } from 'react-spinners-kit'; -import { commentClassName } from '../post-rendering/comment-list-item'; import { useUserClient } from '@smart-signer/lib/auth/use-user-client'; const logger = getLogger('app'); @@ -221,7 +222,7 @@ export function ReplyTextbox({ diff --git a/apps/blog/features/post-rendering/comment-card.tsx b/apps/blog/features/post-rendering/comment-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a204ca9bab92645e9c5713ccf95a4817eb4eb475 --- /dev/null +++ b/apps/blog/features/post-rendering/comment-card.tsx @@ -0,0 +1,476 @@ +'use client'; + +import { Icons } from '@hive/ui/components/icons'; +import parseDate from '@hive/ui/lib/parse-date'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader } from '@hive/ui/components/card'; +import { cn } from '@hive/ui/lib/utils'; +import Link from 'next/link'; +import { Separator } from '@ui/components/separator'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@ui/components/accordion'; +import { useEffect, useRef, useState } from 'react'; +import DetailsCardVoters from '@/blog/features/post-rendering/details-card-voters'; +import { ReplyTextbox } from '../post-editor/reply-textbox'; +import DetailsCardHover from '../list-of-posts/details-card-hover'; +import { IFollowList, Entry } from '@transaction/lib/extended-hive.chain'; +import clsx from 'clsx'; +import { Badge } from '@ui/components/badge'; +import { useTranslation } from '@/blog/i18n/client'; +import { useLocalStorage } from 'usehooks-ts'; +import { useUser } from '@smart-signer/lib/auth/use-user'; +import DialogLogin from '../../components/dialog-login'; +import { UserPopoverCard } from './user-popover-card'; +import { PostDeleteDialog } from './post-delete-dialog'; +import moment from 'moment'; +import dmcaUserList from '@hive/ui/config/lists/dmca-user-list'; +import userIllegalContent from '@hive/ui/config/lists/user-illegal-content'; +import gdprUserList from '@ui/config/lists/gdpr-user-list'; +import RendererContainer from './rendererContainer'; +import { useDeleteCommentMutation } from './hooks/use-comment-mutations'; +import { handleError } from '@ui/lib/handle-error'; +import { CircleSpinner } from 'react-spinners-kit'; +import MutePostDialog from './mute-post-dialog'; +import ChangeTitleDialog from '../community-profile/change-title-dialog'; +import { AlertDialogFlag } from './alert-window-flag'; +import FlagTooltip from './flag-icon'; +import TimeAgo from '@hive/ui/components/time-ago'; +import { getUserAvatarUrl } from '@hive/ui'; +import VotesComponentWrapper from '@/blog/features/votes/votes-component-wrapper'; + +interface CommentListProps { + permissionToMute: Boolean; + comment: Entry; + parent_depth: number; + mutedList: IFollowList[]; + parentPermlink: string; + discussionPermlink: string; + parentAuthor: string; + flagText: string | undefined; + onCommentLinkClick: (hash: string) => void; +} + +const CommentCard = ({ + permissionToMute, + comment, + parent_depth, + mutedList, + parentPermlink, + parentAuthor, + flagText, + discussionPermlink, + onCommentLinkClick +}: CommentListProps) => { + const { t } = useTranslation('common_blog'); + const username = comment.author; + const { user } = useUser(); + const ref = useRef(null); + const [hiddenComment, setHiddenComment] = useState( + comment.stats?.gray || mutedList?.some((x) => x.name === comment.author) + ); + const [openState, setOpenState] = useState(comment.stats?.gray && hiddenComment ? '' : 'item-1'); + const [tempraryHidden, setTemporaryHidden] = useState(false); + const commentId = `@${username}/${comment.permlink}`; + const storageId = `replybox-/${username}/${comment.permlink}-${user.username}`; + const [edit, setEdit] = useState(false); + const [storedBox, storeBox, removeBox] = useLocalStorage(storageId, false); + const [reply, setReply] = useState(storedBox !== undefined ? storedBox : false); + const userFromDMCA = dmcaUserList.some((e) => e === comment.author); + const legalBlockedUser = userIllegalContent.some((e) => e === comment.author); + const userFromGDPR = gdprUserList.some((e) => e === comment.author); + const parentFromGDPR = gdprUserList.some((e) => e === comment.parent_author); + + useEffect(() => { + if (reply) { + storeBox(reply); + } + }, [reply, storeBox]); + + useEffect(() => { + setOpenState(comment.stats?.gray && hiddenComment ? '' : 'item-1'); + setTemporaryHidden(comment.stats?.gray ? true : false); + }, [comment.stats?.gray, hiddenComment]); + const currentDepth = comment.depth - parent_depth; + + const deleteCommentMutation = useDeleteCommentMutation(); + const deleteComment = async (permlink: string) => { + try { + await deleteCommentMutation.mutateAsync({ permlink, discussionPermlink }); + } catch (error) { + handleError(error, { method: 'deleteComment', params: { permlink } }); + } + }; + + // Receive output from dialog and do action according to user's + // response. + const dialogAction = (permlink: string): void => { + if (permlink) { + deleteComment(permlink); + } + }; + + if (userFromGDPR || parentFromGDPR) { + return null; + } + return ( + <> + {currentDepth < 8 ? ( + + + + + + + + + + + {comment._temporary ? ( + + {comment.author} + + ) : ( + <> + + + {comment.author_title ? ( + + {comment.author_title} + + + ) : ( + + )} + { + onCommentLinkClick(`#@${comment.author}/${comment.permlink}`); + }} + > + + + + + + > + )} + + {comment._temporary ? null : !hiddenComment ? ( + + {flagText && comment.community && !user.isLoggedIn ? ( + + {}} /> + + ) : flagText && comment.community && user.isLoggedIn ? ( + + {}} /> + + ) : null} + setOpenState((prev) => (prev === 'item-1' ? '' : 'item-1'))} + /> + + ) : null} + + {!hiddenComment && comment.stats?.gray && openState ? ( + {t('cards.comment_card.will_be_hidden')} + ) : null} + + {comment._temporary ? null : hiddenComment ? ( + + setOpenState((prev) => (prev === 'item-1' ? '' : 'item-1'))} + > + setHiddenComment(false)} + > + {t('cards.comment_card.reveal_comment')}{' '} + + + {flagText && comment.community && !user.isLoggedIn ? ( + + {}} /> + + ) : flagText && comment.community && user.isLoggedIn ? ( + + {}} /> + + ) : null} + + ) : null} + + {comment._temporary ? null : !openState ? ( + + + + + + {'$'} + {comment.payout.toFixed(2)} + + + {comment.children ? ( + <> + + + {comment.children}{' '} + {comment.children > 1 + ? t('cards.comment_card.replies') + : t('cards.comment_card.one_reply')} + + > + ) : null} + + ) : null} + + {!hiddenComment ? ( + setOpenState((prev) => (prev === 'item-1' ? '' : 'item-1'))} + /> + ) : null} + + + + + + {legalBlockedUser ? ( + {t('global.unavailable_for_legal_reasons')} + ) : userFromDMCA ? ( + {t('post_content.body.copyright')} + ) : edit && comment.parent_permlink && comment.parent_author ? ( + + ) : ( + + + + )} + + {' '} + + {comment._temporary ? null : ( + + + + + {'$'} + {comment.payout.toFixed(2)} + + + + {comment.stats && comment.stats.total_votes > 0 ? ( + <> + + + + {comment.stats && comment.stats.total_votes > 1 + ? t('cards.post_card.votes', { votes: comment.stats.total_votes }) + : t('cards.post_card.vote')} + + + + + > + ) : null} + {user && user.isLoggedIn ? ( + { + setReply(!reply), removeBox(); + }} + className="flex items-center hover:cursor-pointer hover:text-destructive" + data-testid="comment-card-footer-reply" + > + {t('cards.comment_card.reply')} + + ) : ( + + + {t('post_content.footer.reply')} + + + )} + {user && user.isLoggedIn && comment.author === user.username ? ( + <> + + { + setEdit(!edit); + }} + className="flex items-center hover:cursor-pointer hover:text-destructive" + data-testid="comment-card-footer-edit" + > + {t('cards.comment_card.edit')} + + > + ) : null} + {comment.replies.length === 0 && + user.isLoggedIn && + comment.author === user.username && + moment().format('YYYY-MM-DDTHH:mm:ss') < comment.payout_at ? ( + <> + + + + {deleteCommentMutation.isLoading ? ( + + ) : ( + t('cards.comment_card.delete') + )} + + + > + ) : null} + {permissionToMute ? ( + + ) : null} + + )} + + + + + + + ) : currentDepth === 8 ? ( + + + {t('cards.comment_card.load_more')}... + + + ) : null} + {reply && user && user.isLoggedIn ? ( + + ) : null} + > + ); +}; + +export default CommentCard; diff --git a/apps/blog/features/post-rendering/comment-list-item.tsx b/apps/blog/features/post-rendering/comment-list-item.tsx deleted file mode 100644 index a085f6d381ebab5eaba00d93579a556b02e99f06..0000000000000000000000000000000000000000 --- a/apps/blog/features/post-rendering/comment-list-item.tsx +++ /dev/null @@ -1,490 +0,0 @@ -'use client'; - -import { Icons } from '@hive/ui/components/icons'; -import parseDate from '@hive/ui/lib/parse-date'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader } from '@hive/ui/components/card'; -import { cn } from '@hive/ui/lib/utils'; -import Link from 'next/link'; -import { Separator } from '@ui/components/separator'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@ui/components/accordion'; -import { useEffect, useRef, useState, type ReactNode } from 'react'; -import DetailsCardVoters from '@/blog/features/post-rendering/details-card-voters'; -import { ReplyTextbox } from '../post-editor/reply-textbox'; -import DetailsCardHover from '../list-of-posts/details-card-hover'; -import { IFollowList, Entry } from '@transaction/lib/extended-hive.chain'; -import clsx from 'clsx'; -import { Badge } from '@ui/components/badge'; -import { useLocalStorage } from 'usehooks-ts'; -import { useUserClient } from '@smart-signer/lib/auth/use-user-client'; -import DialogLogin from '../../components/dialog-login'; - -import { PostDeleteDialog } from './post-delete-dialog'; -import moment from 'moment'; -import dmcaUserList from '@hive/ui/config/lists/dmca-user-list'; -import userIllegalContent from '@hive/ui/config/lists/user-illegal-content'; -import gdprUserList from '@ui/config/lists/gdpr-user-list'; -import RendererContainer from './rendererContainer'; -import { useDeleteCommentMutation } from './hooks/use-comment-mutations'; -import { handleError } from '@ui/lib/handle-error'; -import { CircleSpinner } from 'react-spinners-kit'; -import MutePostDialog from './mute-post-dialog'; -import ChangeTitleDialog from '../community-profile/change-title-dialog'; -import { AlertDialogFlag } from './alert-window-flag'; -import FlagTooltip from './flag-icon'; -import TimeAgo from '@hive/ui/components/time-ago'; -import { getUserAvatarUrl } from '@hive/ui'; -import { UserPopoverCard } from './user-popover-card'; -import { useTranslation } from '@/blog/i18n/client'; -import VotesComponentWrapper from '@/blog/features/votes/votes-component-wrapper'; - -interface CommentListProps { - permissionToMute: Boolean; - comment: Entry; - parent_depth: number; - mutedList: IFollowList[]; - parentPermlink: string; - discussionPermlink: string; - parentAuthor: string; - flagText: string | undefined; - onCommnentLinkClick: (hash: string) => void; - children?: ReactNode; -} -export const commentClassName = - 'font-sanspro text-[12.5px] prose-h1:text-[20px] prose-h2:text-[17.5px] prose-h4:text-[13.7px] sm:text-[13.4px] sm:prose-h1:text-[21.5px] sm:prose-h2:text-[18.7px] sm:prose-h3:text-[16px] sm:prose-h4:text-[14.7px] lg:text-[14.6px] lg:prose-h1:text-[23.3px] lg:prose-h2:text-[20.4px] lg:prose-h3:text-[17.5px] lg:prose-h4:text-[16px] prose-h3:text-[15px] prose-p:mb-[9.6px] prose-p:mt-[1.6px] last:prose-p:mb-[3.2px] prose-img:max-w-[400px] prose-img:max-h-[400px]'; - -const CommentListItem = ({ - permissionToMute, - comment, - parent_depth, - mutedList, - parentPermlink, - parentAuthor, - flagText, - discussionPermlink, - onCommnentLinkClick, - children -}: CommentListProps) => { - const { t } = useTranslation('common_blog'); - const { user } = useUserClient(); - const ref = useRef(null); - const [hiddenComment, setHiddenComment] = useState( - comment.stats?.gray || mutedList?.some((x) => x.name === comment.author) - ); - const [openState, setOpenState] = useState(comment.stats?.gray && hiddenComment ? '' : 'item-1'); - const [tempraryHidden, setTemporaryHidden] = useState(false); - const commentId = `@${comment.author}/${comment.permlink}`; - const storageId = `replybox-/${comment.author}/${comment.permlink}-${user.username}`; - const [edit, setEdit] = useState(false); - const [storedBox, storeBox, removeBox] = useLocalStorage(storageId, false); - const [reply, setReply] = useState(storedBox !== undefined ? storedBox : false); - const userFromDMCA = dmcaUserList.some((e) => e === comment.author); - const legalBlockedUser = userIllegalContent.some((e) => e === comment.author); - const userFromGDPR = gdprUserList.some((e) => e === comment.author); - const parentFromGDPR = gdprUserList.some((e) => e === comment.parent_author); - - useEffect(() => { - if (reply) { - storeBox(reply); - } - }, [reply]); - - useEffect(() => { - setOpenState(comment.stats?.gray && hiddenComment ? '' : 'item-1'); - setTemporaryHidden(comment.stats?.gray ? true : false); - }, [comment.stats?.gray]); - const currentDepth = comment.depth - parent_depth; - - const deleteCommentMutation = useDeleteCommentMutation(); - const deleteComment = async (permlink: string) => { - try { - await deleteCommentMutation.mutateAsync({ permlink, discussionPermlink }); - } catch (error) { - handleError(error, { method: 'deleteComment', params: { permlink } }); - } - }; - - // Receive output from dialog and do action according to user's - // response. - const dialogAction = (permlink: string): void => { - if (permlink) { - deleteComment(permlink); - } - }; - - if (userFromGDPR || parentFromGDPR) { - return null; - } - return ( - <> - {currentDepth < 8 ? ( - - - - - - - - - - - - {comment._temporary ? ( - - {comment.author} - - ) : ( - <> - - - {comment.author_title ? ( - - {comment.author_title} - - - ) : ( - - )} - { - onCommnentLinkClick(`#@${comment.author}/${comment.permlink}`); - }} - > - - - - - - > - )} - - {comment._temporary ? null : !hiddenComment ? ( - - {flagText && comment.community && !user.isLoggedIn ? ( - - {}} /> - - ) : flagText && comment.community && user.isLoggedIn ? ( - - {}} /> - - ) : null} - setOpenState((prev) => (prev === 'item-1' ? '' : 'item-1'))} - /> - - ) : null} - - {!hiddenComment && comment.stats?.gray && openState ? ( - {t('cards.comment_card.will_be_hidden')} - ) : null} - - {comment._temporary ? null : hiddenComment ? ( - - setOpenState((prev) => (prev === 'item-1' ? '' : 'item-1'))} - > - setHiddenComment(false)} - > - {t('cards.comment_card.reveal_comment')}{' '} - - - {flagText && comment.community && !user.isLoggedIn ? ( - - {}} /> - - ) : flagText && comment.community && user.isLoggedIn ? ( - - {}} /> - - ) : null} - - ) : null} - - {comment._temporary ? null : !openState ? ( - - - - - - {'$'} - {comment.payout.toFixed(2)} - - - {comment.children ? ( - <> - - - {comment.children}{' '} - {comment.children > 1 - ? t('cards.comment_card.replies') - : t('cards.comment_card.one_reply')} - - > - ) : null} - - ) : null} - - {!hiddenComment ? ( - setOpenState((prev) => (prev === 'item-1' ? '' : 'item-1'))} - /> - ) : null} - - - - - - {legalBlockedUser ? ( - {t('global.unavailable_for_legal_reasons')} - ) : userFromDMCA ? ( - {t('post_content.body.copyright')} - ) : edit && comment.parent_permlink && comment.parent_author ? ( - - ) : ( - - - - )} - - {' '} - - {comment._temporary ? null : ( - - - - - {'$'} - {comment.payout.toFixed(2)} - - - - {!!comment.stats && comment.stats.total_votes > 0 ? ( - <> - - - - {!!comment.stats && comment.stats.total_votes > 1 - ? t('cards.post_card.votes', { votes: comment.stats.total_votes }) - : t('cards.post_card.vote')} - - - - - > - ) : null} - {user.isLoggedIn ? ( - { - setReply(!reply), removeBox(); - }} - className="flex items-center hover:cursor-pointer hover:text-destructive" - data-testid="comment-card-footer-reply" - > - {t('cards.comment_card.reply')} - - ) : ( - - - {t('post_content.footer.reply')} - - - )} - {user && user.isLoggedIn && comment.author === user.username ? ( - <> - - { - setEdit(!edit); - }} - className="flex items-center hover:cursor-pointer hover:text-destructive" - data-testid="comment-card-footer-edit" - > - {t('cards.comment_card.edit')} - - > - ) : null} - {comment.replies.length === 0 && - user.isLoggedIn && - comment.author === user.username && - moment().format('YYYY-MM-DDTHH:mm:ss') < comment.payout_at ? ( - <> - - - - {deleteCommentMutation.isLoading ? ( - - ) : ( - t('cards.comment_card.delete') - )} - - - > - ) : null} - {permissionToMute ? ( - - ) : null} - - )} - - - - {children ? {children} : null} - - - - - ) : currentDepth === 8 ? ( - - - {t('cards.comment_card.load_more')}... - - - ) : null} - {reply && user && user.isLoggedIn ? ( - - ) : null} - > - ); -}; - -export default CommentListItem; diff --git a/apps/blog/features/post-rendering/comment-list.tsx b/apps/blog/features/post-rendering/comment-list.tsx index 9cda3202f006fb478c6a3311139a600e1b48ad2a..c6de472ea50e2899da3fe8bd38a991e7ba8249e0 100644 --- a/apps/blog/features/post-rendering/comment-list.tsx +++ b/apps/blog/features/post-rendering/comment-list.tsx @@ -1,10 +1,12 @@ 'use client'; -import CommentListItem from '@/blog/features/post-rendering/comment-list-item'; +import CommentCard from '@/blog/features/post-rendering/comment-card'; import { Entry } from '@transaction/lib/extended-hive.chain'; import { IFollowList } from '@transaction/lib/extended-hive.chain'; import clsx from 'clsx'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState, useMemo, useCallback, useTransition } from 'react'; +import CommentsPagination from '@/blog/features/post-rendering/comments-pagination'; +import Loading from '@ui/components/loading'; const CommentList = ({ highestAuthor, @@ -27,33 +29,111 @@ const CommentList = ({ flagText: string | undefined; discussionPermlink: string; }) => { - const [markedHash, setMarkedHash] = useState(""); + const [markedHash, setMarkedHash] = useState(''); useEffect(() => { - if (typeof window !== "undefined") { - setMarkedHash(window.location.hash); - } -}, []); + if (typeof window !== 'undefined') { + setMarkedHash(window.location.hash); + } + }, []); - const arr = useMemo(() => { - if (!data || !parent) return undefined; + const nbCommentsPerPage = 20; + + const topLevelComments = useMemo(() => { + if (!data || !parent) return []; const filtered = data.filter( (x) => x?.parent_author === parent?.author && x?.parent_permlink === parent?.permlink ); - const mutedContent = filtered.filter( - (item) => parent && item.depth === 1 && item.parent_author === parent.author - ); - const unmutedContent = filtered.filter((md) => mutedContent.every((fd) => fd.post_id !== md.post_id)); + const mutedContent = filtered.filter((item) => mutedList?.some((x) => x.name === item.author)); + + const unmutedContent = filtered.filter((item) => mutedContent.every((m) => m.post_id !== item.post_id)); + return [...mutedContent, ...unmutedContent]; - }, [JSON.stringify(data), JSON.stringify(parent)]); + }, [data, parent, mutedList]); + + const [isPending, startTransition] = useTransition(); + const [currentReplyPage, setCurrentReplyPage] = useState(1); + + const commentStartIndex = (currentReplyPage - 1) * nbCommentsPerPage; + const commentEndIndex = commentStartIndex + nbCommentsPerPage; + const nbReplies = topLevelComments.length; + + const handlePaginationClick = useCallback((page: number) => { + startTransition(() => { + setCurrentReplyPage(page); + }); + }, []); + + const renderReplies = useCallback( + (parentComment: Entry, depth: number) => { + if (!data) return null; + + const replies = data.filter( + (x) => x.parent_author === parentComment.author && x.parent_permlink === parentComment.permlink + ); + + if (!replies.length) return null; + + if (depth >= 7) { + const firstHiddenReply = replies[0]; + + return ( + + + Load more... + + + ); + } + + return ( + + {replies.map((reply) => ( + + setMarkedHash(hash)} + /> + {renderReplies(reply, depth + 1)} + + ))} + + ); + }, + [data, discussionPermlink, flagText, highestAuthor, highestPermlink, mutedList, permissionToMute] + ); + return ( - - <> - {!!arr - ? arr.map((comment: Entry, index: number) => ( - + {isPending ? ( + + ) : ( + <> + {parent_depth === 0 && nbReplies > 0 && ( + + )} + + + {topLevelComments.slice(commentStartIndex, commentEndIndex).map((comment) => ( + 1 } )} > - setMarkedHash(hash)} - > - - - - )) - : null} - > - + onCommentLinkClick={(hash) => setMarkedHash(hash)} + /> + {renderReplies(comment, parent_depth + 1)} + + ))} + + + {parent_depth === 0 && nbReplies > 0 && ( + + )} + > + )} + ); }; + export default CommentList; diff --git a/apps/blog/features/post-rendering/comments-pagination.tsx b/apps/blog/features/post-rendering/comments-pagination.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dc3b09fbb5eb33c27ecf01bcb89ec278be8ab14b --- /dev/null +++ b/apps/blog/features/post-rendering/comments-pagination.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useTranslation } from '@/blog/i18n/client'; +import { ReactElement } from 'react'; + +const CommentsPagination = ({ + nbComments, + nbItemsPerPage = 50, + currentPage, + onClick +}: { + nbComments: number; + nbItemsPerPage: number; + currentPage: number; + onClick: (page: number) => void; +}) => { + const { t } = useTranslation('common_blog'); + const nbPages = Math.ceil(nbComments / nbItemsPerPage); + + if (nbPages <= 1) return null; + + const handleClick = (page: number) => onClick(page); + + const pageLinks: ReactElement[] = []; + let lastAddedPage = 0; + + for (let pi = 1; pi <= nbPages; pi++) { + // Show first, last, and pages around current + if (pi === 1 || pi === nbPages || (pi >= currentPage - 2 && pi <= currentPage + 2)) { + pageLinks.push( + handleClick(pi)} + > + {pi} + + ); + lastAddedPage = pi; + } else if (lastAddedPage !== -1 && pageLinks[pageLinks.length - 1]?.key !== `separator-${pi}`) { + // Add ellipsis if there’s a gap + pageLinks.push( + + ... + + ); + lastAddedPage = -1; + } + } + + return ( + + {t('pagination.numberOfPages', { count: nbPages })} + + {t('pagination.pages')} + {pageLinks} + + + ); +}; + +export default CommentsPagination; diff --git a/apps/blog/locales/en/common_blog.json b/apps/blog/locales/en/common_blog.json index 846eab10a0478f64d594e9f5aef975273e743716..b54ee387f6083eaa97b909d8e269ce3849c92253 100644 --- a/apps/blog/locales/en/common_blog.json +++ b/apps/blog/locales/en/common_blog.json @@ -666,5 +666,9 @@ "hot_posts": "hot posts", "trending_posts": "trending posts", "muted_posts": "muted posts" + }, + "pagination": { + "numberOfPages": "There are {{count}} pages", + "pages": "Pages" } } diff --git a/apps/blog/playwright/tests/e2e/comments.spec.ts b/apps/blog/playwright/tests/e2e/comments.spec.ts index 7434efaa443f7d12034a34565b48adddc41ea602..8cbec236783fb072bce8c9b9f431c6a38c7d7d52 100644 --- a/apps/blog/playwright/tests/e2e/comments.spec.ts +++ b/apps/blog/playwright/tests/e2e/comments.spec.ts @@ -220,7 +220,7 @@ test.describe('@gtg - Comments of "hive-160391/@gtg/hive-hardfork-25-jump-starte await postPage.commentListItems.nth(1).locator('..'), 'border-color' ) - ).toBe('rgb(220, 38, 38)'); + ).toBe('rgb(237, 237, 237)'); // background-color of the second comment expect( await postPage.getElementCssPropertyValue( @@ -511,7 +511,7 @@ test.describe('@gtg - Comments of "hive-160391/@gtg/hive-hardfork-25-jump-starte let removeThreeDotsUserAboutUI; if (userPostingJsonMetadata.profile.about) { userAboutAPI = await userPostingJsonMetadata.profile.about; - userAboutUI = await postPage.userAboutPopoverCard.textContent() || ''; + userAboutUI = (await postPage.userAboutPopoverCard.textContent()) || ''; removeThreeDotsUserAboutUI = userAboutUI.replace('...', ''); // console.log('userAboutAPI: ', await userAboutAPI); expect(userAboutAPI).toContain(await removeThreeDotsUserAboutUI); @@ -735,11 +735,11 @@ test.describe('@gtg - Comments of "hive-160391/@gtg/hive-hardfork-25-jump-starte await commentViewPage.page.waitForLoadState('domcontentloaded'); // Validate re-title of the comment's thread - comment view page is loaded await expect(commentViewPage.getReArticleTitle).toHaveText(reArticleTitle); - await postPage.articleBody.waitFor({state: 'visible'}); + await postPage.articleBody.waitFor({ state: 'visible' }); // Click 'View the full context' await commentViewPage.getViewFullContext.click(); await postPage.page.waitForLoadState('domcontentloaded'); - await postPage.articleBody.waitFor({state: 'visible'}); + await postPage.articleBody.waitFor({ state: 'visible' }); // Validate that the post page of Hive HardFork 25 Jump Starter Kit of gtg is loaded expect(await postPage.articleTitle).toHaveText('Hive HardFork 25 Jump Starter Kit'); expect(await postPage.articleAuthorName).toHaveText('gtg'); @@ -749,7 +749,7 @@ test.describe('@gtg - Comments of "hive-160391/@gtg/hive-hardfork-25-jump-starte await postPage.commentListItems.nth(1).locator('..'), 'border-color' ) - ).toBe('rgb(220, 38, 38)'); + ).toBe('rgb(237, 237, 237)'); // Validate that the first comment is not selected by red border expect( await postPage.getElementCssPropertyValue( @@ -770,10 +770,10 @@ test.describe('@gtg - Comments of "hive-160391/@gtg/hive-hardfork-25-jump-starte await commentViewPage.page.waitForLoadState('domcontentloaded'); // Validate re-title of the comment's thread - comment view page is loaded await expect(commentViewPage.getReArticleTitle).toHaveText(reArticleTitle); - await postPage.articleBody.waitFor({state: 'visible'}); + await postPage.articleBody.waitFor({ state: 'visible' }); // Click 'View the direct parent' commentViewPage.getViewDirectParent.click(); - await postPage.commentListLocator.first().waitFor({state: 'visible'}); + await postPage.commentListLocator.first().waitFor({ state: 'visible' }); // Validate that the `sicarius` comment is visibled on the comment view page await expect(await commentViewPage.getMainCommentAuthorData).toBeVisible(); await expect(await commentViewPage.getMainCommentAuthorNameLink).toHaveText('sicarius'); @@ -818,7 +818,7 @@ test.describe('Load more... comments in the post', () => { await postPage.gotoPostPage('leofinance', 'leo-curation', 'organic-curation-report-week-25'); await expect(await postPage.articleTitle).toHaveText('Organic Curation report - Week 25, 2023'); // Validate the number of visible posts - await postPage.commentListLocator.first().waitFor({state: 'visible'}); + await postPage.commentListLocator.first().waitFor({ state: 'visible' }); await expect((await postPage.commentListItems.all()).length).toBe(12); // Validate the author and content of the first post in the Trending filter await expect(await postPage.commentAuthorLink.first()).toHaveText('infinity0'); diff --git a/packages/tailwindcss/globals.css b/packages/tailwindcss/globals.css index 58aad37df7dec5f4217ae1ec38205295007df25b..901b7a21fbb3f9890ce300b999d0017be9686f84 100644 --- a/packages/tailwindcss/globals.css +++ b/packages/tailwindcss/globals.css @@ -96,6 +96,12 @@ } } +@layer components { + .hive-comment { + @apply font-sanspro text-[12.5px] prose-h1:text-[20px] prose-h2:text-[17.5px] prose-h4:text-[13.7px] sm:text-[13.4px] sm:prose-h1:text-[21.5px] sm:prose-h2:text-[18.7px] sm:prose-h3:text-[16px] sm:prose-h4:text-[14.7px] lg:text-[14.6px] lg:prose-h1:text-[23.3px] lg:prose-h2:text-[20.4px] lg:prose-h3:text-[17.5px] lg:prose-h4:text-[16px] prose-h3:text-[15px] prose-p:mb-[9.6px] prose-p:mt-[1.6px] last:prose-p:mb-[3.2px] prose-img:max-w-[400px] prose-img:max-h-[400px]; + } +} + .prose :where(code):not(:where([class~='not-prose'], [class~='not-prose'] *)):after, .prose :where(code):not(:where([class~='not-prose'], [class~='not-prose'] *)):before { content: none; diff --git a/packages/transaction/lib/hive-chain-service.ts b/packages/transaction/lib/hive-chain-service.ts index ac82bddd6d484ab84d3713fd12bef855b24cb84b..922ecefcd8f1c1bb90cbbbb906888cb6bbe246ec 100644 --- a/packages/transaction/lib/hive-chain-service.ts +++ b/packages/transaction/lib/hive-chain-service.ts @@ -53,7 +53,12 @@ export class HiveChainService { } // Set promise result in this class' static property and return // it here as well. - await this.setHiveChain({ apiEndpoint, chainId: siteConfig.chainId, restApiEndpoint: apiEndpoint }); + await this.setHiveChain({ + apiEndpoint, + chainId: siteConfig.chainId, + restApiEndpoint: apiEndpoint, + apiTimeout: 10000 + }); return HiveChainService.hiveChain; }; @@ -67,7 +72,7 @@ export class HiveChainService { return HiveChainService.hiveChain; } - reuseHiveChain(): HiveChain | undefined{ + reuseHiveChain(): HiveChain | undefined { if (HiveChainService.hiveChain) return HiveChainService.hiveChain; return undefined; } @@ -78,50 +83,51 @@ export class HiveChainService { HiveChainService.hiveChain = hiveChain.extend().extendRest({ 'hivesense-api': { posts: { - urlPath: "posts", + urlPath: 'posts', search: { - urlPath: "search", - method: "GET" + urlPath: 'search', + method: 'GET' }, author: { - urlPath: "{author}", + urlPath: '{author}', permlink: { - urlPath: "{permlink}", + urlPath: '{permlink}', similar: { - urlPath: "similar", - method: "GET" + urlPath: 'similar', + method: 'GET' } } }, byIds: { - urlPath: "by-ids", - method: "POST" + urlPath: 'by-ids', + method: 'POST' }, byIdsQuery: { - urlPath: "by-ids-query", - method: "GET" + urlPath: 'by-ids-query', + method: 'GET' } }, authors: { - urlPath: "authors", + urlPath: 'authors', search: { - urlPath: "search", - method: "GET" + urlPath: 'search', + method: 'GET' } - }, + } }, - method: "GET", - 'hivemind-api': { - "accountsOperations": { - urlPath: 'accounts/{account-name}/operations', - } - }, - 'hafah-api': { - 'operation-types': { - urlPath: 'operation-types' - } + method: 'GET', + 'hivemind-api': { + accountsOperations: { + urlPath: 'accounts/{account-name}/operations' + } + }, + 'hafah-api': { + 'operation-types': { + urlPath: 'operation-types' } - }); + } + }); + const storedAiSearchEndpoint = this.storage.getItem('ai-search-endpoint'); let apiEndpoint: string = storedAiSearchEndpoint ? JSON.parse(storedAiSearchEndpoint) : ''; if (!apiEndpoint) { diff --git a/packages/ui/config/public-vars.ts b/packages/ui/config/public-vars.ts index ad1abd231671e169c37519f3896ac05fd89e5b28..371662d66bc4463a225c33ae9a86b61959c38a55 100644 --- a/packages/ui/config/public-vars.ts +++ b/packages/ui/config/public-vars.ts @@ -5,6 +5,7 @@ import env from '@beam-australia/react-env'; export const configuredAIDomain = env('AI_DOMAIN') ?? 'https://api.syncad.com'; export const configuredSiteDomain = env('SITE_DOMAIN') ?? 'https://hive.blog/'; export const configuredImagesEndpoint = env('IMAGES_ENDPOINT') ?? 'https://images.hive.blog/'; -export const configuredApiEndpoint = env('API_ENDPOINT') ?? 'https://api.hive.blog/'; +export const configuredApiEndpoint = env('API_ENDPOINT') ?? 'https://api.hive.blog'; export const configuredBlogDomain = env('BLOG_DOMAIN') ?? 'https://hive.blog/'; -export const configuredSessionTime = env('APP_SESSION_TIME') ?? configuredSiteDomain.includes('wallet') ? 900 : 64800; \ No newline at end of file +export const configuredSessionTime = + (env('APP_SESSION_TIME') ?? configuredSiteDomain.includes('wallet')) ? 900 : 64800;