Skip to content

Commit

Permalink
Implement cursor pagination and infinity scroll in posts
Browse files Browse the repository at this point in the history
  • Loading branch information
RatulHasan committed May 17, 2024
1 parent 7266b5c commit 090fde4
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 49 deletions.
6 changes: 5 additions & 1 deletion app/Http/Controllers/Frontend/BoardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,17 @@ public function show(Request $request, Board $board)
}

$postsQuery->orderBy($orderBy, $request->sort === 'oldest' ? 'asc' : 'desc');
$posts = $postsQuery->get();
$posts = $postsQuery->cursorPaginate(20);

$data = [
'board' => $board,
'posts' => $posts,
];

if ($request->cursor) {
return response()->json($data);
}

return inertia('Frontend/Board/Show', $data);
}

Expand Down
39 changes: 39 additions & 0 deletions resources/js/Components/BackToTop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React, { useEffect, useState } from 'react';
import { ChevronUpIcon } from '@heroicons/react/24/outline';

const BackToTop = () => {
const [showTopButton, setShowTopButton] = useState(false);

useEffect(() => {
const handleScroll = () => {
if (window.scrollY > 900) {
setShowTopButton(true);
} else {
setShowTopButton(false);
}
};

window.addEventListener('scroll', handleScroll);

return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);

const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};

return (
showTopButton && (
<button
onClick={scrollToTop}
className="fixed bottom-8 right-8 bg-indigo-600 text-white p-2 rounded-full shadow-lg hover:bg-indigo-500 focus:outline-none dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<ChevronUpIcon className="h-6 w-6" />
</button>
)
);
};

export default BackToTop;
159 changes: 111 additions & 48 deletions resources/js/Pages/Frontend/Board/Show.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,35 @@
import React, { useState } from 'react';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Head, Link, router } from '@inertiajs/react';
import {
MagnifyingGlassIcon,
ChevronUpIcon,
ChatBubbleLeftIcon,
} from '@heroicons/react/24/outline';
import { MagnifyingGlassIcon, ChatBubbleLeftIcon } from '@heroicons/react/24/outline';

import FrontendLayout from '@/Layouts/FrontendLayout';
import { BoardType, PageProps, PostType, User } from '@/types';
import { BoardType, PageProps, PostType } from '@/types';
import PostForm from './PostForm';
import axios from 'axios';
import VoteButton from '@/Components/VoteButton';
import BackToTop from '@/Components/BackToTop';

type Props = {
posts: PostType[];
posts: {
data: PostType[];
next_page_url: string | null;
};
board: BoardType;
};

const ShowBoard = ({ auth, posts, board }: PageProps<Props>) => {
const [allPosts, setAllPosts] = useState<PostType[]>(posts);
const [allPosts, setAllPosts] = useState<PostType[]>(posts.data);
const [nextPageUrl, setNextPageUrl] = useState<string | null>(posts.next_page_url);
const [loading, setLoading] = useState(false);
const observer = useRef<IntersectionObserver | null>(null);

// get sort key from url param
// Get sort key from URL param
const urlParams = new URLSearchParams(window.location.search);
const sort = urlParams.get('sort');
const [sortKey, setSortKey] = useState(sort || 'voted');

const toggleVote = (post: PostType) => {
if (!auth.user) {
return;
}
if (!auth.user) return;

// send a ajax request for vote
axios.post(route('post.vote', [board.slug, post.slug])).then((response) => {
Expand All @@ -47,19 +48,41 @@ const ShowBoard = ({ auth, posts, board }: PageProps<Props>) => {

const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;

setSortKey(value);
router.visit(route('board.show', { board: board.slug, sort: value }), { replace: true });
};

const loadMorePosts = useCallback(() => {
if (loading || !nextPageUrl) return;
setLoading(true);

router.visit(
route('board.show', {
board: board.slug,
sort: value,
}),
{
replace: true,
const url = new URL(nextPageUrl);
// @TODO: Need to add search query later
url.searchParams.set('sort', sortKey);

axios.get(url.toString()).then((response) => {
setAllPosts((prevPosts) => [...prevPosts, ...response.data.posts.data]);
setNextPageUrl(response.data.posts.next_page_url);
setLoading(false);
});
}, [loading, nextPageUrl]);

const lastPostRef = useCallback((node: HTMLDivElement) => {
if (loading) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMorePosts();
}
);
};
});
if (node) observer.current.observe(node);
}, [loading, loadMorePosts]);

useEffect(() => {
return () => {
if (observer.current) observer.current.disconnect();
};
}, []);

return (
<div>
Expand Down Expand Up @@ -104,43 +127,83 @@ const ShowBoard = ({ auth, posts, board }: PageProps<Props>) => {
</div>

<div className="divide-y dark:divide-gray-700">
{allPosts.map((post) => (
<div
key={post.id}
className="p-4 flex justify-between hover:bg-slate-50 dark:hover:bg-slate-800"
>
<Link
href={route('post.show', [board.slug, post.slug])}
className="flex flex-col flex-1"
>
<div className="text-sm font-semibold dark:text-gray-300 mb-1">
{post.title}
</div>
<div className="text-sm text-gray-500 line-clamp-2">
{post.body}
</div>
<div className="text-xs text-gray-500 flex mt-2">
<ChatBubbleLeftIcon className="h-4 w-4 inline-block mr-1.5" />
<span>{post.comments}</span>
{allPosts.map((post, index) => {
if (allPosts.length === index + 1) {
return (
<div
key={post.id}
ref={lastPostRef}
className="p-4 flex justify-between hover:bg-slate-50 dark:hover:bg-slate-800"
>
<Link
href={route('post.show', [board.slug, post.slug])}
className="flex flex-col flex-1"
>
<div className="text-sm font-semibold dark:text-gray-300 mb-1">
{post.title}
</div>
<div className="text-sm text-gray-500 line-clamp-2">
{post.body}
</div>
<div className="text-xs text-gray-500 flex mt-2">
<ChatBubbleLeftIcon className="h-4 w-4 inline-block mr-1.5" />
<span>{post.comments}</span>
</div>
</Link>
<div className="text-sm text-gray-500">
<div className="ml-4">
<VoteButton post={post} />
</div>
</div>
</div>
</Link>
<div className="text-sm text-gray-500">
<div className="ml-4">
<VoteButton post={post} />
);
} else {
return (
<div
key={post.id}
className="p-4 flex justify-between hover:bg-slate-50 dark:hover:bg-slate-800"
>
<Link
href={route('post.show', [board.slug, post.slug])}
className="flex flex-col flex-1"
>
<div className="text-sm font-semibold dark:text-gray-300 mb-1">
{post.title}
</div>
<div className="text-sm text-gray-500 line-clamp-2">
{post.body}
</div>
<div className="text-xs text-gray-500 flex mt-2">
<ChatBubbleLeftIcon className="h-4 w-4 inline-block mr-1.5" />
<span>{post.comments}</span>
</div>
</Link>
<div className="text-sm text-gray-500">
<div className="ml-4">
<VoteButton post={post} />
</div>
</div>
</div>
</div>
</div>
))}
);
}
})}

{allPosts.length === 0 && (
<div className="p-4 text-sm text-center dark:text-gray-300">
No posts found.
</div>
)}
</div>

{loading && (
<div className="p-4 text-sm text-center dark:text-gray-300">
Loading more posts...
</div>
)}
</div>
</div>
</div>
<BackToTop />
</div>
);
};
Expand Down

0 comments on commit 090fde4

Please sign in to comment.