Skip to content

Commit 48ff568

Browse files
committed
feat(micro): replace infinite scroll with cursor-based pagination
Switch from client-side infinite scroll with offset-based pagination to server-side cursor-based pagination. This approach: - Uses timestamp cursors for stable pagination across data changes - Supports bidirectional navigation (next/previous) - Removes reliance on client-side JavaScript and Intersection Observer - Provides explicit navigation buttons instead of automatic loading - Maintains correct chronological ordering when navigating between pages
1 parent b08f54d commit 48ff568

1 file changed

Lines changed: 83 additions & 91 deletions

File tree

src/pages/micro.astro

Lines changed: 83 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,68 @@ import Layout from "@/layouts/Layout.astro";
44
55
export const prerender = false;
66
7-
// Pagination settings
7+
// Cursor-based pagination settings
88
const PAGE_SIZE = 20;
9-
const page = parseInt(Astro.url.searchParams.get("page") || "1");
10-
const offset = (page - 1) * PAGE_SIZE;
9+
const cursorParam = Astro.url.searchParams.get("cursor");
10+
const direction = Astro.url.searchParams.get("dir") || "next";
1111
1212
// Get micro posts from D1 via content collection
1313
const allPosts = (await getCollection("micro")).sort(
1414
(a, b) => b.data.createdAt.valueOf() - a.data.createdAt.valueOf()
1515
);
1616
17-
const posts = allPosts.slice(offset, offset + PAGE_SIZE);
18-
const hasMore = allPosts.length > offset + PAGE_SIZE;
19-
const totalPosts = allPosts.length;
17+
let posts: typeof allPosts;
18+
let hasNext = false;
19+
let hasPrev = false;
20+
let nextCursor: number | null = null;
21+
let prevCursor: number | null = null;
22+
23+
if (!cursorParam) {
24+
// First page - get newest posts
25+
posts = allPosts.slice(0, PAGE_SIZE);
26+
hasNext = allPosts.length > PAGE_SIZE;
27+
if (hasNext && posts.length > 0) {
28+
nextCursor = posts[posts.length - 1].data.createdAt.valueOf();
29+
}
30+
} else {
31+
const cursor = parseInt(cursorParam);
32+
33+
if (direction === "prev") {
34+
// Previous page - get posts newer than cursor
35+
const newerPosts = allPosts.filter((post) => post.data.createdAt.valueOf() > cursor);
36+
// Take the last PAGE_SIZE posts from newer posts (to maintain chronological order)
37+
posts = newerPosts.slice(Math.max(0, newerPosts.length - PAGE_SIZE));
38+
39+
// Check if there are more newer posts
40+
hasPrev = newerPosts.length > PAGE_SIZE;
41+
if (hasPrev && posts.length > 0) {
42+
prevCursor = posts[0].data.createdAt.valueOf();
43+
}
44+
45+
// Check if there are older posts
46+
const olderPosts = allPosts.filter((post) => post.data.createdAt.valueOf() < cursor);
47+
hasNext = olderPosts.length > 0;
48+
if (hasNext && posts.length > 0) {
49+
nextCursor = posts[posts.length - 1].data.createdAt.valueOf();
50+
}
51+
} else {
52+
// Next page - get posts older than cursor
53+
const olderPosts = allPosts.filter((post) => post.data.createdAt.valueOf() < cursor);
54+
posts = olderPosts.slice(0, PAGE_SIZE);
55+
56+
hasNext = olderPosts.length > PAGE_SIZE;
57+
if (hasNext && posts.length > 0) {
58+
nextCursor = posts[posts.length - 1].data.createdAt.valueOf();
59+
}
60+
61+
// Check if there are newer posts
62+
const newerPosts = allPosts.filter((post) => post.data.createdAt.valueOf() > cursor);
63+
hasPrev = newerPosts.length > 0;
64+
if (hasPrev && posts.length > 0) {
65+
prevCursor = posts[0].data.createdAt.valueOf();
66+
}
67+
}
68+
}
2069
2170
// Format date helper
2271
function formatDate(date: Date): string {
@@ -42,10 +91,9 @@ function formatDate(date: Date): string {
4291
<Layout title="Micro - Just Be" description="Micro blog posts from Just Be">
4392
<header class="mb-2">
4493
<h1 class="font-bold">~/micro</h1>
45-
<p class="text-fg-2 text-sm">{totalPosts} {totalPosts === 1 ? "post" : "posts"}</p>
4694
</header>
4795

48-
<div id="posts-container" class="space-y-2">
96+
<div class="space-y-2">
4997
{
5098
posts.map((post) => (
5199
<article class="border-fg-2 border p-2">
@@ -64,89 +112,33 @@ function formatDate(date: Date): string {
64112
</div>
65113

66114
{
67-
hasMore && (
68-
<div id="loading-indicator" class="text-fg-2 mt-4 text-center">
69-
Loading more...
70-
</div>
115+
(hasPrev || hasNext) && (
116+
<nav class="mt-4 flex justify-between">
117+
<div>
118+
{hasPrev && prevCursor ? (
119+
<a
120+
href={`/micro?cursor=${prevCursor}&dir=prev`}
121+
class="border-fg-2 hover:bg-fg-2 hover:text-bg-0 border px-2 py-1 transition-colors"
122+
>
123+
Previous
124+
</a>
125+
) : (
126+
<span class="border-fg-2 text-fg-2 border px-2 py-1 opacity-50">Previous</span>
127+
)}
128+
</div>
129+
<div>
130+
{hasNext && nextCursor ? (
131+
<a
132+
href={`/micro?cursor=${nextCursor}&dir=next`}
133+
class="border-fg-2 hover:bg-fg-2 hover:text-bg-0 border px-2 py-1 transition-colors"
134+
>
135+
Next
136+
</a>
137+
) : (
138+
<span class="border-fg-2 text-fg-2 border px-2 py-1 opacity-50">Next</span>
139+
)}
140+
</div>
141+
</nav>
71142
)
72143
}
73-
74-
<div id="sentinel" class="h-px"></div>
75144
</Layout>
76-
77-
<script define:vars={{ hasMore: hasMore, pageSize: PAGE_SIZE }}>
78-
if (hasMore) {
79-
let currentPage = 1;
80-
let isLoading = false;
81-
82-
const postsContainer = document.getElementById("posts-container");
83-
const loadingIndicator = document.getElementById("loading-indicator");
84-
const sentinel = document.getElementById("sentinel");
85-
86-
async function loadMorePosts() {
87-
if (isLoading) return;
88-
isLoading = true;
89-
90-
if (loadingIndicator) {
91-
loadingIndicator.style.display = "block";
92-
}
93-
94-
try {
95-
currentPage++;
96-
const response = await fetch(`/micro?page=${currentPage}`);
97-
const html = await response.text();
98-
99-
const parser = new DOMParser();
100-
const doc = parser.parseFromString(html, "text/html");
101-
const newPosts = doc.querySelectorAll("#posts-container > article");
102-
const newHasMore = doc.getElementById("loading-indicator") !== null;
103-
104-
if (postsContainer) {
105-
newPosts.forEach((post) => {
106-
postsContainer.appendChild(post.cloneNode(true));
107-
});
108-
}
109-
110-
if (!newHasMore) {
111-
observer.disconnect();
112-
if (loadingIndicator) {
113-
loadingIndicator.textContent = "No more posts";
114-
}
115-
}
116-
} catch (error) {
117-
console.error("Failed to load more posts:", error);
118-
if (loadingIndicator) {
119-
loadingIndicator.textContent = "Failed to load more posts";
120-
}
121-
} finally {
122-
isLoading = false;
123-
if (loadingIndicator && isLoading === false) {
124-
loadingIndicator.style.display = "none";
125-
}
126-
}
127-
}
128-
129-
const observer = new IntersectionObserver(
130-
(entries) => {
131-
entries.forEach((entry) => {
132-
if (entry.isIntersecting) {
133-
loadMorePosts();
134-
}
135-
});
136-
},
137-
{
138-
rootMargin: "100px",
139-
}
140-
);
141-
142-
if (sentinel) {
143-
observer.observe(sentinel);
144-
}
145-
}
146-
</script>
147-
148-
<style>
149-
#loading-indicator {
150-
display: none;
151-
}
152-
</style>

0 commit comments

Comments
 (0)