Skip to content

wip: v0 infinite scrolling #1510

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions next.config.js
Original file line number Diff line number Diff line change
@@ -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,
},
Expand Down Expand Up @@ -48,4 +48,3 @@ const nextConfig = {
};

module.exports = nextConfig;

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions pages/api/posts.page.ts
Original file line number Diff line number Diff line change
@@ -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' });
}
}
174 changes: 110 additions & 64 deletions pages/blog/index.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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', '');
Expand All @@ -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'
Expand All @@ -93,6 +110,12 @@ export default function StaticMarkdownPage({
const [currentFilterTags, setCurrentFilterTags] =
useState<blogCategories[]>(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;
Expand All @@ -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'];
Expand 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;
Expand All @@ -184,7 +221,8 @@ export default function StaticMarkdownPage({

// Collect all unique categories across posts.
const allTagsSet = new Set<string>();
blogPosts.forEach((post) => {

initialPosts.forEach((post) => {
getCategories(post.frontmatter).forEach((cat) => allTagsSet.add(cat));
});
const allTags = ['All', ...Array.from(allTagsSet)];
Expand Down Expand Up @@ -300,7 +338,7 @@ export default function StaticMarkdownPage({

{/* Blog Posts Grid */}
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-6 grid-flow-row mb-20 bg-white dark:bg-slate-800 mx-auto p-4'>
{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);
Expand Down Expand Up @@ -417,6 +455,14 @@ export default function StaticMarkdownPage({
</section>
);
})}

{hasMore && (
<div ref={ref} className='col-span-full flex justify-center p-4'>
<div className='animate-pulse text-slate-500 dark:text-slate-300'>
Loading more posts...
</div>
</div>
)}
</div>
</div>
</SectionContext.Provider>
Expand Down
14 changes: 14 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading