From 00a06b3524b790745d186eb21d242553af6130c9 Mon Sep 17 00:00:00 2001 From: Nabhag8848 Date: Wed, 12 Mar 2025 14:42:33 +0530 Subject: [PATCH 1/4] feat: v0 infinite scrolling --- next.config.js | 5 +- package.json | 1 + pages/api/posts.page.ts | 47 +++++ pages/blog/index.page.tsx | 358 +++++++++++++++++++++----------------- yarn.lock | 14 ++ 5 files changed, 263 insertions(+), 162 deletions(-) create mode 100644 pages/api/posts.page.ts diff --git a/next.config.js b/next.config.js index 3b9750f83..358573f79 100644 --- a/next.config.js +++ b/next.config.js @@ -1,8 +1,8 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, - output: 'export', - pageExtensions: ['page.tsx'], + // output: 'export', -> question on this as we can't api in case of static export. + pageExtensions: ['page.tsx', 'page.ts'], images: { unoptimized: true, }, @@ -48,4 +48,3 @@ const nextConfig = { }; module.exports = nextConfig; - diff --git a/package.json b/package.json index 662304e73..2cafa7089 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "node-ical": "0.20.1", "react": "18.3.1", "react-dom": "18.3.1", + "react-intersection-observer": "^9.16.0", "react-syntax-highlighter": "^15.6.1", "react-text-truncate": "^0.19.0", "reading-time": "^1.5.0", diff --git a/pages/api/posts.page.ts b/pages/api/posts.page.ts new file mode 100644 index 000000000..aaf1769ff --- /dev/null +++ b/pages/api/posts.page.ts @@ -0,0 +1,47 @@ +import fs from 'fs'; +import matter from 'gray-matter'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +const PATH = 'pages/blog/posts'; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const { page = 1, type = 'All' } = req.query; + const POSTS_PER_PAGE = 10; + + try { + const files = fs.readdirSync(PATH); + const blogPosts = files + .filter((file) => file.substr(-3) === '.md') + .map((fileName) => { + const slug = fileName.replace('.md', ''); + const fullFileName = fs.readFileSync(`${PATH}/${slug}.md`, 'utf-8'); + const { data: frontmatter, content } = matter(fullFileName); + return { + slug: slug, + frontmatter, + content, + }; + }) + .filter((post) => { + if (type === 'All') return true; + return post.frontmatter.type === type; + }) + .sort((a, b) => { + const dateA = new Date(a.frontmatter.date).getTime(); + const dateB = new Date(b.frontmatter.date).getTime(); + return dateB - dateA; + }); + + const startIndex = (Number(page) - 1) * POSTS_PER_PAGE; + const endIndex = startIndex + POSTS_PER_PAGE; + const paginatedPosts = blogPosts.slice(startIndex, endIndex); + + res.status(200).json({ + posts: paginatedPosts, + totalPosts: blogPosts.length, + hasMore: endIndex < blogPosts.length, + }); + } catch (error) { + res.status(500).json({ error: 'Error loading posts' }); + } +} diff --git a/pages/blog/index.page.tsx b/pages/blog/index.page.tsx index a866346cf..f2c74027f 100644 --- a/pages/blog/index.page.tsx +++ b/pages/blog/index.page.tsx @@ -8,9 +8,9 @@ import readingTime from 'reading-time'; const PATH = 'pages/blog/posts'; import TextTruncate from 'react-text-truncate'; import generateRssFeed from './generateRssFeed'; -import { useRouter } from 'next/router'; import { SectionContext } from '~/context'; import Image from 'next/image'; +import { useInView } from 'react-intersection-observer'; type Author = { name: string; @@ -26,9 +26,11 @@ export type blogCategories = | 'Update' | 'Opinion'; +const POSTS_PER_PAGE = 10; + export async function getStaticProps({ query }: { query: any }) { const files = fs.readdirSync(PATH); - const blogPosts = files + const allBlogPosts = files .filter((file) => file.substr(-3) === '.md') .map((fileName) => { const slug = fileName.replace('.md', ''); @@ -44,73 +46,115 @@ export async function getStaticProps({ query }: { query: any }) { }; }); - await generateRssFeed(blogPosts); + // Sort posts by date + const sortedPosts = allBlogPosts.sort((a, b) => { + const dateA = new Date(a.frontmatter.date).getTime(); + const dateB = new Date(b.frontmatter.date).getTime(); + return dateB - dateA; + }); + + const setOfTags = sortedPosts.map((tag) => tag.frontmatter.type); + // Get initial posts + const initialPosts = sortedPosts.slice(0, POSTS_PER_PAGE); const filterTag: string = query?.type || 'All'; + await generateRssFeed(allBlogPosts); // Keep RSS feed generation with all posts + return { props: { - blogPosts, + initialPosts, filterTag, + setOfTags, }, }; } - -function isValidCategory(category: any): category is blogCategories { - return [ - 'All', - 'Community', - 'Case Study', - 'Engineering', - 'Update', - 'Opinion', - 'Documentation', - ].includes(category); -} - export default function StaticMarkdownPage({ - blogPosts, + initialPosts, filterTag, + setOfTags, }: { - blogPosts: any[]; + initialPosts: any[]; filterTag: any; + setOfTags: any[]; }) { - const router = useRouter(); + const [posts, setPosts] = useState(initialPosts); const [currentFilterTag, setCurrentFilterTag] = useState( filterTag || 'All', ); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const { ref, inView } = useInView(); + // Reset posts when filter changes useEffect(() => { - const { query } = router; - if (query.type && isValidCategory(query.type)) { - setCurrentFilterTag(query.type); - } - }, [router.query]); + setPosts(initialPosts); + setPage(1); + setHasMore(true); + }, [currentFilterTag, initialPosts]); + // Load more posts when scrolling useEffect(() => { - // Set the filter tag based on the initial query parameter when the page loads - setCurrentFilterTag(filterTag); - }, [filterTag]); + const loadMorePosts = async () => { + if (inView && hasMore && !loading) { + setLoading(true); + try { + const nextPage = page + 1; + const res = await fetch( + `http://localhost:3000/api/posts?page=${nextPage}&type=${currentFilterTag}`, + ); + const data = await res.json(); + + if (data.posts.length) { + setPosts((prevPosts) => [...prevPosts, ...data.posts]); + setPage(nextPage); + setHasMore(data.hasMore); + } else { + setHasMore(false); + } + } catch (error) { + console.error('Error loading more posts:', error); + } finally { + setLoading(false); + } + } + }; - const handleClick = (event: React.MouseEvent) => { - event.preventDefault(); // Prevent default scrolling behavior + loadMorePosts(); + }, [inView, hasMore, loading, page, currentFilterTag]); + + const handleFilterClick = async ( + event: React.MouseEvent, + ) => { + event.preventDefault(); const clickedTag = event.currentTarget.value as blogCategories; - if (clickedTag === 'All') { - setCurrentFilterTag('All'); - history.replaceState(null, '', '/blog'); // Update the URL without causing a scroll - } else if (isValidCategory(clickedTag)) { - setCurrentFilterTag(clickedTag); - history.replaceState(null, '', `/blog?type=${clickedTag}`); // Update URL + setCurrentFilterTag(clickedTag); + + try { + const res = await fetch(`/api/posts?page=1&type=${clickedTag}`); + const data = await res.json(); + setPosts(data.posts); + setHasMore(data.hasMore); + setPage(1); + + if (clickedTag === 'All') { + history.replaceState(null, '', '/blog'); + } else { + history.replaceState(null, '', `/blog?type=${clickedTag}`); + } + } catch (error) { + console.error('Error filtering posts:', error); } }; - const recentBlog = blogPosts.sort((a, b) => { + + const recentBlog = initialPosts.sort((a, b) => { const dateA = new Date(a.frontmatter.date).getTime(); const dateB = new Date(b.frontmatter.date).getTime(); return dateA < dateB ? 1 : -1; }); const timeToRead = Math.ceil(readingTime(recentBlog[0].content).minutes); - const setOfTags: any[] = blogPosts.map((tag) => tag.frontmatter.type); const spreadTags: any[] = [...setOfTags]; const allTags = [...new Set(spreadTags)]; //add tag for all @@ -211,7 +255,7 @@ export default function StaticMarkdownPage({ @@ -269,50 +334,52 @@ export default function StaticMarkdownPage({ - {/* filterTag === frontmatter.type && */} + {/* Blog Posts Grid */}
- {posts.map((blogPost: any) => { + {posts.map((blogPost: any, idx: number) => { const { frontmatter, content } = blogPost; const date = new Date(frontmatter.date); - const timeToRead = Math.ceil(readingTime(content).minutes); + const postTimeToRead = Math.ceil(readingTime(content).minutes); return (
-
+
-
-
+
+ {frontmatter.title} +
+
-
-
{ - e.preventDefault(); - e.stopPropagation(); - - if (frontmatter.type) { - setCurrentFilterTag(frontmatter.type); - history.replaceState( - null, - '', - `/blog?type=${frontmatter.type}`, - ); - } - }} - > - {frontmatter.type || 'Unknown Type'} -
+ {/* Display each category as a clickable badge */} +
+ {getCategories(frontmatter).map((cat, index) => ( +
{ + e.preventDefault(); + e.stopPropagation(); + toggleCategory(cat); + }} + > + {cat || 'Unknown'} +
+ ))}
-
+
{frontmatter.title}
-
-
+
{(frontmatter.authors || []).map( (author: Author, index: number) => ( @@ -346,14 +407,7 @@ export default function StaticMarkdownPage({ ), )}
- -
+
{frontmatter.authors.length > 2 ? ( <> @@ -379,7 +433,6 @@ export default function StaticMarkdownPage({ ) )}
-
{frontmatter.date && ( @@ -390,7 +443,7 @@ export default function StaticMarkdownPage({ })} )}{' '} - · {timeToRead} min read + · {postTimeToRead} min read
From b9c2590aebd70069ebf675a442d1377f25783a89 Mon Sep 17 00:00:00 2001 From: Idan Levi <29idan29@gmail.com> Date: Fri, 20 Jun 2025 21:57:37 +0300 Subject: [PATCH 4/4] format adjustments --- pages/blog/index.page.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/pages/blog/index.page.tsx b/pages/blog/index.page.tsx index 78918112f..0132ce7f7 100644 --- a/pages/blog/index.page.tsx +++ b/pages/blog/index.page.tsx @@ -35,7 +35,6 @@ const getCategories = (frontmatter: any): blogCategories[] => { return Array.isArray(cat) ? cat : [cat]; }; - const isValidCategory = (category: string): category is blogCategories => { return [ 'All', @@ -50,7 +49,6 @@ const isValidCategory = (category: string): category is blogCategories => { const POSTS_PER_PAGE = 10; - export async function getStaticProps({ query }: { query: any }) { const files = fs.readdirSync(PATH); const allBlogPosts = files @@ -103,7 +101,6 @@ export default function StaticMarkdownPage({ }) { const router = useRouter(); - // Initialize the filter as an array. If "All" or not specified, we show all posts. const initialFilters = filterTag && filterTag !== 'All' @@ -119,7 +116,6 @@ export default function StaticMarkdownPage({ const [hasMore, setHasMore] = useState(true); const { ref, inView } = useInView(); - // When the router query changes, update the filters. useEffect(() => { const { query } = router; @@ -133,7 +129,6 @@ export default function StaticMarkdownPage({ // Reset posts when filter changes useEffect(() => { - setPosts(initialPosts); setPage(1); setHasMore(true); @@ -173,7 +168,6 @@ export default function StaticMarkdownPage({ }, [inView, hasMore, loading, page, currentFilterTags]); const toggleCategory = async (tag: blogCategories) => { - let newTags: blogCategories[] = []; if (tag === 'All') { newTags = ['All']; @@ -192,7 +186,6 @@ export default function StaticMarkdownPage({ } } - setCurrentFilterTags(newTags); try { @@ -210,14 +203,12 @@ export default function StaticMarkdownPage({ } } catch (error) { console.error('Error filtering posts:', error); - } }; // First, sort all posts by date descending (for fallback sorting) const postsSortedByDate = [...initialPosts].sort((a, b) => { - const dateA = new Date(a.frontmatter.date).getTime(); const dateB = new Date(b.frontmatter.date).getTime(); return dateB - dateA; @@ -232,7 +223,6 @@ export default function StaticMarkdownPage({ const allTagsSet = new Set(); initialPosts.forEach((post) => { - getCategories(post.frontmatter).forEach((cat) => allTagsSet.add(cat)); }); const allTags = ['All', ...Array.from(allTagsSet)]; @@ -348,9 +338,7 @@ export default function StaticMarkdownPage({ {/* Blog Posts Grid */}
- {posts.map((blogPost: any, idx: number) => { - const { frontmatter, content } = blogPost; const date = new Date(frontmatter.date); const postTimeToRead = Math.ceil(readingTime(content).minutes); @@ -475,7 +463,6 @@ export default function StaticMarkdownPage({
)} -