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 ed3214aa7..07d2cbe4d 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,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..9a9f85f03 --- /dev/null +++ b/pages/api/posts.page.ts @@ -0,0 +1,65 @@ +import fs from 'fs'; +import matter from 'gray-matter'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +const PATH = 'pages/blog/posts'; + +const getCategories = (frontmatter: any): string[] => { + const cat = frontmatter.categories || frontmatter.type; + if (!cat) return []; + return Array.isArray(cat) ? cat : [cat]; +}; + +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; + + // Handle multiple categories (comma-separated) + const filterCategories = (type as string) + .split(',') + .map((cat) => cat.trim()); + const postCategories = getCategories(post.frontmatter); + + // Check if any of the post's categories match any of the filter categories + return postCategories.some((cat) => + filterCategories.some( + (filterCat) => filterCat.toLowerCase() === cat.toLowerCase(), + ), + ); + }) + .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 65fdfb3a4..0132ce7f7 100644 --- a/pages/blog/index.page.tsx +++ b/pages/blog/index.page.tsx @@ -8,9 +8,10 @@ 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'; +import { useRouter } from 'next/router'; type Author = { name: string; @@ -34,9 +35,23 @@ const getCategories = (frontmatter: any): blogCategories[] => { return Array.isArray(cat) ? cat : [cat]; }; +const isValidCategory = (category: string): category is blogCategories => { + return [ + 'All', + 'Community', + 'Case Study', + 'Engineering', + 'Update', + 'Opinion', + 'Documentation', + ].includes(category); +}; + +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', ''); @@ -52,38 +67,40 @@ 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, }: { - blogPosts: any[]; + initialPosts: any[]; filterTag: any; + setOfTags: any[]; }) { const router = useRouter(); + // Initialize the filter as an array. If "All" or not specified, we show all posts. const initialFilters = filterTag && filterTag !== 'All' @@ -93,6 +110,12 @@ export default function StaticMarkdownPage({ const [currentFilterTags, setCurrentFilterTags] = useState(initialFilters); + const [posts, setPosts] = useState(initialPosts); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const { ref, inView } = useInView(); + // When the router query changes, update the filters. useEffect(() => { const { query } = router; @@ -104,15 +127,47 @@ export default function StaticMarkdownPage({ } }, [router.query]); + // Reset posts when filter changes useEffect(() => { - const tags = - filterTag && filterTag !== 'All' - ? filterTag.split(',').filter(isValidCategory) - : ['All']; - setCurrentFilterTags(tags); - }, [filterTag]); - - const toggleCategory = (tag: blogCategories) => { + setPosts(initialPosts); + setPage(1); + setHasMore(true); + }, [currentFilterTags, initialPosts]); + + // Load more posts when scrolling + useEffect(() => { + const loadMorePosts = async () => { + if (inView && hasMore && !loading) { + setLoading(true); + try { + const nextPage = page + 1; + const filterString = currentFilterTags.includes('All') + ? 'All' + : currentFilterTags.join(','); + const res = await fetch( + `/api/posts?page=${nextPage}&type=${filterString}`, + ); + 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); + } + } + }; + + loadMorePosts(); + }, [inView, hasMore, loading, page, currentFilterTags]); + + const toggleCategory = async (tag: blogCategories) => { let newTags: blogCategories[] = []; if (tag === 'All') { newTags = ['All']; @@ -130,48 +185,30 @@ export default function StaticMarkdownPage({ newTags = ['All']; } } + setCurrentFilterTags(newTags); - if (newTags.includes('All')) { - history.replaceState(null, '', '/blog'); - } else { - history.replaceState(null, '', `/blog?type=${newTags.join(',')}`); + + try { + const filterString = newTags.includes('All') ? 'All' : newTags.join(','); + const res = await fetch(`/api/posts?page=1&type=${filterString}`); + const data = await res.json(); + setPosts(data.posts); + setHasMore(data.hasMore); + setPage(1); + + if (newTags.includes('All')) { + history.replaceState(null, '', '/blog'); + } else { + history.replaceState(null, '', `/blog?type=${newTags.join(',')}`); + } + } catch (error) { + console.error('Error filtering posts:', error); } }; // First, sort all posts by date descending (for fallback sorting) - const postsSortedByDate = [...blogPosts].sort((a, b) => { - const dateA = new Date(a.frontmatter.date).getTime(); - const dateB = new Date(b.frontmatter.date).getTime(); - return dateB - dateA; - }); - // Filter posts based on selected categories. - // If "All" is selected, all posts are returned. - const filteredPosts = postsSortedByDate.filter((post) => { - if (currentFilterTags.includes('All') || currentFilterTags.length === 0) - return true; - const postCategories = getCategories(post.frontmatter); - return postCategories.some((cat) => - currentFilterTags.some( - (filter) => filter.toLowerCase() === cat.toLowerCase(), - ), - ); - }); - - const sortedFilteredPosts = filteredPosts.sort((a, b) => { - const aMatches = getCategories(a.frontmatter).filter((cat) => - currentFilterTags.some( - (filter) => filter.toLowerCase() === cat.toLowerCase(), - ), - ).length; - const bMatches = getCategories(b.frontmatter).filter((cat) => - currentFilterTags.some( - (filter) => filter.toLowerCase() === cat.toLowerCase(), - ), - ).length; - if (aMatches !== bMatches) { - return bMatches - aMatches; - } + 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; @@ -184,7 +221,8 @@ export default function StaticMarkdownPage({ // Collect all unique categories across posts. const allTagsSet = new Set(); - blogPosts.forEach((post) => { + + initialPosts.forEach((post) => { getCategories(post.frontmatter).forEach((cat) => allTagsSet.add(cat)); }); const allTags = ['All', ...Array.from(allTagsSet)]; @@ -300,7 +338,7 @@ export default function StaticMarkdownPage({ {/* Blog Posts Grid */}
- {sortedFilteredPosts.map((blogPost: any, idx: number) => { + {posts.map((blogPost: any, idx: number) => { const { frontmatter, content } = blogPost; const date = new Date(frontmatter.date); const postTimeToRead = Math.ceil(readingTime(content).minutes); @@ -417,6 +455,14 @@ export default function StaticMarkdownPage({ ); })} + + {hasMore && ( +
+
+ Loading more posts... +
+
+ )}
diff --git a/yarn.lock b/yarn.lock index 279aa1e66..5903da92a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8602,6 +8602,7 @@ __metadata: prettier: "npm:3.3.3" react: "npm:18.3.1" react-dom: "npm:18.3.1" + react-intersection-observer: "npm:^9.16.0" react-syntax-highlighter: "npm:^15.6.1" react-text-truncate: "npm:^0.19.0" reading-time: "npm:^1.5.0" @@ -10280,6 +10281,19 @@ __metadata: languageName: node linkType: hard +"react-intersection-observer@npm:^9.16.0": + version: 9.16.0 + resolution: "react-intersection-observer@npm:9.16.0" + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react-dom: + optional: true + checksum: 10c0/2aee5103fb460c6e5a3ab5a4fdc8c69a5215e23699a12d7857f25ba765fed48eacbed94ca835d79edf00c0edcc9c4fbb5bb057a373402d0551718546810e0e48 + languageName: node + linkType: hard + "react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1"