diff --git a/apps/blog/app/[param]/[p2]/[permlink]/content.tsx b/apps/blog/app/[param]/[p2]/[permlink]/content.tsx index 89e37f1700746bd19abd7664192edadc835c6099..44c9fa546f30118b3383d4a89d8a124ebb7c7f0a 100644 --- a/apps/blog/app/[param]/[p2]/[permlink]/content.tsx +++ b/apps/blog/app/[param]/[p2]/[permlink]/content.tsx @@ -64,12 +64,13 @@ import { useLocalStorage } from 'usehooks-ts'; import { useUserClient } from '@smart-signer/lib/auth/use-user-client'; import VotesComponentWrapper from '@/blog/features/votes/votes-component-wrapper'; -const PostContent = () => { +const PostContent = ({ initialCommentsPage = 1 }: { initialCommentsPage?: number }) => { const searchParams = useSearchParams(); const params = useParams<{ param: string; p2: string; permlink: string }>(); const router = useRouter(); const pathname = usePathname(); const commentSort = searchParams?.get('sort') || 'trending'; + const commentsPage = parseInt(searchParams?.get('comments_page') || String(initialCommentsPage), 10) || 1; const author = params?.p2.replace('%40', '') ?? ''; const category = params?.param ?? ''; const permlink = params?.permlink ?? ''; @@ -688,6 +689,8 @@ const PostContent = () => { parent={postData} parent_depth={postData.depth} discussionPermlink={permlink} + initialPage={commentsPage} + sort={commentSort} /> ) : ( diff --git a/apps/blog/app/[param]/[p2]/[permlink]/page.tsx b/apps/blog/app/[param]/[p2]/[permlink]/page.tsx index 79756c563726a290ca12742dadc1b919d4920552..bde21a2189961cde6912e8ff09354247023710fb 100644 --- a/apps/blog/app/[param]/[p2]/[permlink]/page.tsx +++ b/apps/blog/app/[param]/[p2]/[permlink]/page.tsx @@ -14,10 +14,15 @@ import { getLogger } from '@ui/lib/logging'; const logger = getLogger('app'); const PostPage = async ({ - params: { param, p2, permlink } + params: { param, p2, permlink }, + searchParams }: { params: { param: string; p2: string; permlink: string }; + searchParams: { [key: string]: string | string[] | undefined }; }) => { + const commentsPage = typeof searchParams?.comments_page === 'string' + ? parseInt(searchParams.comments_page, 10) || 1 + : 1; const queryClient = getQueryClient(); const username = p2.replace('%40', ''); const community = param; @@ -59,7 +64,7 @@ const PostPage = async ({ return ( }> - + ); diff --git a/apps/blog/app/api/comments/[author]/[permlink]/route.ts b/apps/blog/app/api/comments/[author]/[permlink]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9143d76df7a7b1886072d445d11e1ce825aee57 --- /dev/null +++ b/apps/blog/app/api/comments/[author]/[permlink]/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDiscussion } from '@transaction/lib/bridge-api'; +import { sorter, SortOrder } from '@/blog/lib/sorter'; +import { Entry } from '@transaction/lib/extended-hive.chain'; + +// In-memory cache for discussion data +// Key: "author/permlink", Value: { data, timestamp } +const discussionCache = new Map(); +const CACHE_TTL = 60000; // 60 seconds + +interface CommentResponse { + comments: Entry[]; + totalCount: number; + nextCursor: { author: string; permlink: string } | null; +} + +async function getCachedDiscussion(author: string, permlink: string): Promise { + const cacheKey = `${author}/${permlink}`; + const cached = discussionCache.get(cacheKey); + const now = Date.now(); + + if (cached && now - cached.timestamp < CACHE_TTL) { + return cached.data; + } + + // Fetch fresh data + const discussionData = await getDiscussion(author, permlink); + + if (!discussionData) { + return []; + } + + // Convert Record to Entry[] and filter out the root post + const comments = Object.values(discussionData).filter( + (entry) => entry.author !== author || entry.permlink !== permlink + ); + + // Cache the result + discussionCache.set(cacheKey, { data: comments, timestamp: now }); + + return comments; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ author: string; permlink: string }> } +): Promise> { + try { + const { author, permlink } = await params; + const searchParams = request.nextUrl.searchParams; + + const sort = (searchParams.get('sort') as SortOrder) || SortOrder.new; + const afterAuthor = searchParams.get('after_author') || ''; + const afterPermlink = searchParams.get('after_permlink') || ''; + const limit = Math.min(parseInt(searchParams.get('limit') || '50', 10), 100); + + // Get cached or fresh discussion + const comments = await getCachedDiscussion(author, permlink); + + if (comments.length === 0) { + return NextResponse.json({ + comments: [], + totalCount: 0, + nextCursor: null + }); + } + + // Make a copy to avoid mutating cached data + const sortedComments = [...comments]; + + // Sort comments + sorter(sortedComments, sort); + + // Find cursor position + let startIndex = 0; + if (afterAuthor && afterPermlink) { + const cursorIndex = sortedComments.findIndex( + (c) => c.author === afterAuthor && c.permlink === afterPermlink + ); + if (cursorIndex !== -1) { + startIndex = cursorIndex + 1; + } + } + + // Slice to get the page + const slice = sortedComments.slice(startIndex, startIndex + limit); + const hasMore = startIndex + limit < sortedComments.length; + + // Build next cursor + const nextCursor = hasMore && slice.length > 0 + ? { author: slice[slice.length - 1].author, permlink: slice[slice.length - 1].permlink } + : null; + + return NextResponse.json({ + comments: slice, + totalCount: sortedComments.length, + nextCursor + }); + } catch (error) { + console.error('Error fetching comments:', error); + return NextResponse.json({ error: 'Failed to fetch comments' }, { status: 500 }); + } +} diff --git a/apps/blog/features/post-rendering/comment-list.tsx b/apps/blog/features/post-rendering/comment-list.tsx index 9cda3202f006fb478c6a3311139a600e1b48ad2a..282c00af19dd45e401789493b3fb752ac0109785 100644 --- a/apps/blog/features/post-rendering/comment-list.tsx +++ b/apps/blog/features/post-rendering/comment-list.tsx @@ -3,8 +3,13 @@ import CommentListItem from '@/blog/features/post-rendering/comment-list-item'; import { Entry } from '@transaction/lib/extended-hive.chain'; import { IFollowList } from '@transaction/lib/extended-hive.chain'; +import { Button } from '@ui/components/button'; import clsx from 'clsx'; +import Link from 'next/link'; import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'next-i18next'; + +const COMMENTS_PER_PAGE = 50; const CommentList = ({ highestAuthor, @@ -15,7 +20,9 @@ const CommentList = ({ parent_depth, mutedList, flagText, - discussionPermlink + discussionPermlink, + initialPage = 1, + sort = 'trending' }: { highestAuthor: string; highestPermlink: string; @@ -26,8 +33,14 @@ const CommentList = ({ mutedList: IFollowList[]; flagText: string | undefined; discussionPermlink: string; + initialPage?: number; + sort?: string; }) => { + const { t } = useTranslation('common_blog'); const [markedHash, setMarkedHash] = useState(""); + // Start with initialPage worth of comments shown + const [displayLimit, setDisplayLimit] = useState(COMMENTS_PER_PAGE * initialPage); + const isRootLevel = parent.depth === 0; useEffect(() => { if (typeof window !== "undefined") { @@ -47,11 +60,23 @@ const CommentList = ({ const unmutedContent = filtered.filter((md) => mutedContent.every((fd) => fd.post_id !== md.post_id)); return [...mutedContent, ...unmutedContent]; }, [JSON.stringify(data), JSON.stringify(parent)]); + + // Only limit at root level to prevent browser crash with 1000+ comments + const visibleComments = isRootLevel && arr ? arr.slice(0, displayLimit) : arr; + const hasMoreComments = isRootLevel && arr && arr.length > displayLimit; + const remainingCount = arr ? arr.length - displayLimit : 0; + + // Calculate pagination info for crawler-friendly links + const totalComments = arr?.length || 0; + const totalPages = Math.ceil(totalComments / COMMENTS_PER_PAGE); + const currentPage = Math.ceil(displayLimit / COMMENTS_PER_PAGE); + const nextPage = currentPage < totalPages ? currentPage + 1 : null; + return (
    <> - {!!arr - ? arr.map((comment: Entry, index: number) => ( + {!!visibleComments + ? visibleComments.map((comment: Entry, index: number) => (
    )) : null} + {hasMoreComments && ( +
  • + {/* JS-enabled button for better UX */} + + {/* Visible link for crawlers - works without JS */} + {nextPage && ( + + )} +
  • + )}
); diff --git a/apps/blog/locales/ar/common_blog.json b/apps/blog/locales/ar/common_blog.json index 0371b4ee617a333784686138d9bfac5ffa90666a..17a71d5edce29787a0a5272ded73791426c74114 100644 --- a/apps/blog/locales/ar/common_blog.json +++ b/apps/blog/locales/ar/common_blog.json @@ -463,6 +463,11 @@ "community_rules": "قواعد المجتمع", "cancel": "إلغاء", "ok": "موافق" + }, + "comments": { + "show_more": "Show more comments ({{count}} remaining)", + "pagination_label": "Comment pagination", + "page_link": "Page {{page}} of {{total}}" } }, "submit_page": { @@ -621,4 +626,4 @@ "trending_posts": "المشاركات الرائجة", "muted_posts": "المشاركات الصامتة" } -} +} \ No newline at end of file diff --git a/apps/blog/locales/en/common_blog.json b/apps/blog/locales/en/common_blog.json index 846eab10a0478f64d594e9f5aef975273e743716..30f6859f74675dc18b3c6c1535d3e6dd72db371f 100644 --- a/apps/blog/locales/en/common_blog.json +++ b/apps/blog/locales/en/common_blog.json @@ -458,6 +458,11 @@ "we_are_just_verifying": "We are just verifying with you that you want to continue.", "open_link": "Open Link" }, + "comments": { + "show_more": "Show more comments ({{count}} remaining)", + "pagination_label": "Comment pagination", + "page_link": "Page {{page}} of {{total}}" + }, "footer": { "and_more": "and {{value}} more", "in": "in", diff --git a/apps/blog/locales/es/common_blog.json b/apps/blog/locales/es/common_blog.json index 6ac72425aeff09298a6db4aa84a23020d92c3fd2..8b5ae42fd1a211ce1510aa2627ae23f8bf5b5844 100644 --- a/apps/blog/locales/es/common_blog.json +++ b/apps/blog/locales/es/common_blog.json @@ -415,6 +415,11 @@ "community_rules": "Reglas de la comunidad", "cancel": "Cancelar", "ok": "Aceptar" + }, + "comments": { + "show_more": "Show more comments ({{count}} remaining)", + "pagination_label": "Comment pagination", + "page_link": "Page {{page}} of {{total}}" } }, "submit_page": { @@ -573,4 +578,4 @@ "trending_posts": "publicaciones de tendencia", "muted_posts": "publicaciones silenciadas" } -} +} \ No newline at end of file diff --git a/apps/blog/locales/fr/common_blog.json b/apps/blog/locales/fr/common_blog.json index d8ea61ff6fc0e14625d88709de4523a454dd2e7d..cf25869ab6b44400de9a61ee5054236e3fb0069b 100644 --- a/apps/blog/locales/fr/common_blog.json +++ b/apps/blog/locales/fr/common_blog.json @@ -415,6 +415,11 @@ "community_rules": "Règles de la communauté", "cancel": "Annuler", "ok": "OK" + }, + "comments": { + "show_more": "Show more comments ({{count}} remaining)", + "pagination_label": "Comment pagination", + "page_link": "Page {{page}} of {{total}}" } }, "submit_page": { @@ -509,7 +514,6 @@ "no_results": "Nothing was found.", "error": "There was an error" }, - "settings_page": { "invalid_url": "URL invalide", "name_is_too_long": "Le nom est trop long", @@ -574,4 +578,4 @@ "trending_posts": "posts tendances", "muted_posts": "posts muets" } -} +} \ No newline at end of file diff --git a/apps/blog/locales/it/common_blog.json b/apps/blog/locales/it/common_blog.json index b6d53befee5bb17e8af3154ecaecfe0b74d222cc..6d4731f3858194c502be88260aa84cc269bf299b 100644 --- a/apps/blog/locales/it/common_blog.json +++ b/apps/blog/locales/it/common_blog.json @@ -415,6 +415,11 @@ "community_rules": "Regole della comunità", "cancel": "Annulla", "ok": "OK" + }, + "comments": { + "show_more": "Show more comments ({{count}} remaining)", + "pagination_label": "Comment pagination", + "page_link": "Page {{page}} of {{total}}" } }, "submit_page": { @@ -573,4 +578,4 @@ "trending_posts": "post di tendenza", "muted_posts": "post silenziati" } -} +} \ No newline at end of file diff --git a/apps/blog/locales/ja/common_blog.json b/apps/blog/locales/ja/common_blog.json index e2672cd7998d4f7f51ef08f80cf131fe21678a93..4ca97c5252a13c6e1d0d56bc85856bdba3291e36 100644 --- a/apps/blog/locales/ja/common_blog.json +++ b/apps/blog/locales/ja/common_blog.json @@ -415,6 +415,11 @@ "community_rules": "コミュニティ規則", "cancel": "キャンセル", "ok": "OK" + }, + "comments": { + "show_more": "Show more comments ({{count}} remaining)", + "pagination_label": "Comment pagination", + "page_link": "Page {{page}} of {{total}}" } }, "submit_page": { @@ -573,4 +578,4 @@ "trending_posts": "トレンド投稿", "muted_posts": "ミュートされた投稿" } -} +} \ No newline at end of file diff --git a/apps/blog/locales/pl/common_blog.json b/apps/blog/locales/pl/common_blog.json index a2b65c6e150026b158fdb825654c134bcde35867..b1874f86bb5eb81690f567507abdb25fbe000911 100644 --- a/apps/blog/locales/pl/common_blog.json +++ b/apps/blog/locales/pl/common_blog.json @@ -379,6 +379,11 @@ "we_are_just_verifying": "Właśnie sprawdzamy, czy chcesz kontynuować.", "open_link": "Otwórz Link" }, + "comments": { + "show_more": "Pokaż więcej komentarzy ({{count}} pozostało)", + "pagination_label": "Comment pagination", + "page_link": "Page {{page}} of {{total}}" + }, "footer": { "and_more": "i {{value}} więcej", "in": "w", @@ -587,4 +592,4 @@ "trending_posts": "trendingowe posty", "muted_posts": "wyciszone posty" } -} +} \ No newline at end of file diff --git a/apps/blog/locales/ru/common_blog.json b/apps/blog/locales/ru/common_blog.json index 5453b1058111382d44d0459e727ddd3263c8b8d7..a8dc3f9fd98f7fdcadf053e15c23c7f090ba0a11 100644 --- a/apps/blog/locales/ru/common_blog.json +++ b/apps/blog/locales/ru/common_blog.json @@ -421,6 +421,11 @@ "community_rules": "Правила сообщества", "cancel": "Отмена", "ok": "OK" + }, + "comments": { + "show_more": "Show more comments ({{count}} remaining)", + "pagination_label": "Comment pagination", + "page_link": "Page {{page}} of {{total}}" } }, "submit_page": { @@ -579,4 +584,4 @@ "trending_posts": "трендовые посты", "muted_posts": "приглушенные посты" } -} +} \ No newline at end of file diff --git a/apps/blog/locales/zh/common_blog.json b/apps/blog/locales/zh/common_blog.json index 15869955f8ecc091242cd14428bfba27848d20c8..561430dfc461d1744e3f3970dff72b7f973107a1 100644 --- a/apps/blog/locales/zh/common_blog.json +++ b/apps/blog/locales/zh/common_blog.json @@ -415,6 +415,11 @@ "community_rules": "社区规则", "cancel": "取消", "ok": "确定" + }, + "comments": { + "show_more": "Show more comments ({{count}} remaining)", + "pagination_label": "Comment pagination", + "page_link": "Page {{page}} of {{total}}" } }, "submit_page": { @@ -573,4 +578,4 @@ "trending_posts": "趋势帖子", "muted_posts": "静音帖子" } -} +} \ No newline at end of file