summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2026-01-30 20:45:07 +0100
committerDawid Rycerz <dawid@rycerz.xyz>2026-01-30 20:45:07 +0100
commit650249e1a8fe7d6645bb712026930dd7e8906ef8 (patch)
tree4a4f5c46c72f7b8b0f6bef21ce6938e8a8978084
parent2345a208663efff76837d1228bf14b8847f3177f (diff)
feat(blog): add next/previous post navigation scoped by category
Navigate between posts within the same category (regular, microblog, archived). Newer post links left, older post links right. Includes i18n support for English and Polish. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--SPRINT.md12
-rw-r--r--src/components/blog/PostNavigation.astro39
-rw-r--r--src/data/post.ts39
-rw-r--r--src/i18n/translations.ts8
-rw-r--r--src/layouts/BlogPost.astro6
-rw-r--r--src/pages/posts/[...slug].astro17
6 files changed, 107 insertions, 14 deletions
diff --git a/SPRINT.md b/SPRINT.md
index ba4c150..05d7ee1 100644
--- a/SPRINT.md
+++ b/SPRINT.md
@@ -7,17 +7,17 @@ Goal: Initialize project tooling for Claude Code
## In Progress
## Backlog (Prioritized)
-- [ ] **[FEATURE-002]** Import posts from old Mastodon instance
- - Fetch posts from https://mastodon.com.pl/@knightdave using the same approach as the Pleroma loader
- - Integrate into the existing content collection alongside Pleroma posts
-- [ ] **[FEATURE-003]** Add next/previous post navigation on blog post pages
- - Add links to the previous and next posts on each blog post page
- - Allow readers to quickly navigate between posts in sequence
- [ ] **[FEATURE-004]** Add page number pagination on tag pages
- Show numbered page links (e.g. "1, 2, 3, ..., 11") on tag listing pages like `/tags/microblog/`
- Let users see total page count and jump to specific pages
## Completed This Sprint
+- [x] **[FEATURE-003]** Add next/previous post navigation on blog post pages
+ - Completed: 2026-01-30
+ - Notes: Category-scoped navigation (regular/microblog/archived) with i18n support (commit 468d7c4)
+- [x] **[FEATURE-002]** Import posts from old Mastodon instance
+ - Completed: 2026-01-30
+ - Notes: Imported via Mastodon API with local media caching (commit 3850218)
- [x] **[FEATURE-001]** Add caching for Pleroma post fetching
- Completed: 2026-01-29
- Notes: Incremental fetching via Astro 5 MetaStore + Mastodon since_id API. Supports force refresh via env var, config option, and cache TTL.
diff --git a/src/components/blog/PostNavigation.astro b/src/components/blog/PostNavigation.astro
new file mode 100644
index 0000000..037836f
--- /dev/null
+++ b/src/components/blog/PostNavigation.astro
@@ -0,0 +1,39 @@
+---
+import type { CollectionEntry } from "astro:content";
+import { t } from "@/i18n/translations";
+
+interface Props {
+ prevPost: CollectionEntry<"post"> | null | undefined;
+ nextPost: CollectionEntry<"post"> | null | undefined;
+ language?: string | undefined;
+}
+
+const { prevPost, nextPost, language } = Astro.props;
+---
+
+{
+ (prevPost || nextPost) && (
+ <nav aria-label="Post navigation" class="mt-8 flex items-center gap-x-4">
+ {prevPost && (
+ <a
+ class="hover:text-accent me-auto py-2"
+ data-astro-prefetch
+ href={`/posts/${prevPost.id}/`}
+ >
+ <span class="sr-only">{t(language, "newerPostSr")}</span>
+ {t(language, "newerPost")}
+ </a>
+ )}
+ {nextPost && (
+ <a
+ class="hover:text-accent ms-auto py-2"
+ data-astro-prefetch
+ href={`/posts/${nextPost.id}/`}
+ >
+ <span class="sr-only">{t(language, "olderPostSr")}</span>
+ {t(language, "olderPost")}
+ </a>
+ )}
+ </nav>
+ )
+}
diff --git a/src/data/post.ts b/src/data/post.ts
index 85cc0d0..08a6678 100644
--- a/src/data/post.ts
+++ b/src/data/post.ts
@@ -1,4 +1,5 @@
import { type CollectionEntry, getCollection } from "astro:content";
+import { collectionDateSort } from "@/utils/date";
/** filter out draft posts based on the environment and optionally archived posts */
export async function getAllPosts(includeArchived = false): Promise<CollectionEntry<"post">[]> {
@@ -62,3 +63,41 @@ export function getUniqueTagsWithCount(posts: CollectionEntry<"post">[]): [strin
),
].sort((a, b) => b[1] - a[1]);
}
+
+export type PostCategory = "regular" | "microblog" | "archived";
+
+/** Determine the category of a post based on its tags. "archived" takes precedence over "microblog". */
+export function getPostCategory(post: CollectionEntry<"post">): PostCategory {
+ const tags = post.data.tags;
+ if (tags.includes("archived")) return "archived";
+ if (tags.includes("microblog")) return "microblog";
+ return "regular";
+}
+
+export interface PostNavigation {
+ prevPost: CollectionEntry<"post"> | null;
+ nextPost: CollectionEntry<"post"> | null;
+}
+
+/** Get previous (newer) and next (older) posts within the same category.
+ * Posts are sorted newest-first. prevPost = newer, nextPost = older.
+ */
+export function getPostNavigation(
+ allPosts: CollectionEntry<"post">[],
+ currentPost: CollectionEntry<"post">,
+): PostNavigation {
+ const category = getPostCategory(currentPost);
+ const sameCategoryPosts = allPosts
+ .filter((p) => getPostCategory(p) === category)
+ .sort(collectionDateSort);
+
+ const currentIndex = sameCategoryPosts.findIndex((p) => p.id === currentPost.id);
+
+ return {
+ prevPost: currentIndex > 0 ? (sameCategoryPosts[currentIndex - 1] ?? null) : null,
+ nextPost:
+ currentIndex < sameCategoryPosts.length - 1
+ ? (sameCategoryPosts[currentIndex + 1] ?? null)
+ : null,
+ };
+}
diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts
index 5659257..0cead23 100644
--- a/src/i18n/translations.ts
+++ b/src/i18n/translations.ts
@@ -4,12 +4,20 @@ export const translations = {
backToTop: "Back to top",
updated: "Updated:",
viewMoreWithTag: "View more blogs with the tag",
+ newerPost: "← Newer post",
+ olderPost: "Older post →",
+ newerPostSr: "Go to newer post:",
+ olderPostSr: "Go to older post:",
},
pl: {
viewOriginalPost: "Zobacz oryginalny wpis na Fediversum →",
backToTop: "Powrót na górę",
updated: "Zaktualizowano:",
viewMoreWithTag: "Zobacz więcej wpisów z tagiem",
+ newerPost: "← Następny wpis",
+ olderPost: "Poprzedni wpis →",
+ newerPostSr: "Przejdź do następnego wpisu:",
+ olderPostSr: "Przejdź do poprzedniego wpisu:",
},
} as const;
diff --git a/src/layouts/BlogPost.astro b/src/layouts/BlogPost.astro
index 764cd80..73f994b 100644
--- a/src/layouts/BlogPost.astro
+++ b/src/layouts/BlogPost.astro
@@ -2,15 +2,18 @@
import type { CollectionEntry } from "astro:content";
import Masthead from "@/components/blog/Masthead.astro";
+import PostNavigation from "@/components/blog/PostNavigation.astro";
import { t } from "@/i18n/translations";
import BaseLayout from "./Base.astro";
interface Props {
post: CollectionEntry<"post">;
+ prevPost: CollectionEntry<"post"> | null | undefined;
+ nextPost: CollectionEntry<"post"> | null | undefined;
}
-const { post } = Astro.props;
+const { post, prevPost, nextPost } = Astro.props;
const { ogImage, title, description, updatedDate, publishDate, language } = post.data;
const socialImage = ogImage ?? `/og-image/${post.id}.png`;
const articleDate = updatedDate?.toISOString() ?? publishDate.toISOString();
@@ -49,6 +52,7 @@ const articleDate = updatedDate?.toISOString() ?? publishDate.toISOString();
}
</div>
</article>
+ <PostNavigation prevPost={prevPost} nextPost={nextPost} language={language} />
<button
class="hover:border-link fixed end-4 bottom-8 z-90 flex h-10 w-10 translate-y-28 cursor-pointer items-center justify-center rounded-full border-2 border-transparent bg-zinc-200 text-3xl opacity-0 transition-all transition-discrete duration-300 data-[show=true]:translate-y-0 data-[show=true]:opacity-100 sm:end-8 sm:h-12 sm:w-12 dark:bg-zinc-700"
data-show="false"
diff --git a/src/pages/posts/[...slug].astro b/src/pages/posts/[...slug].astro
index 76e4f28..b66d911 100644
--- a/src/pages/posts/[...slug].astro
+++ b/src/pages/posts/[...slug].astro
@@ -1,24 +1,27 @@
---
import { render } from "astro:content";
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
-import { getAllPosts } from "@/data/post";
+import { getAllPosts, getPostNavigation } from "@/data/post";
import PostLayout from "@/layouts/BlogPost.astro";
// if you're using an adaptor in SSR mode, getStaticPaths wont work -> https://docs.astro.build/en/guides/routing/#modifying-the-slug-example-for-ssr
export const getStaticPaths = (async () => {
const blogEntries = await getAllPosts(true); // Include archived posts for direct access
- return blogEntries.map((post) => ({
- params: { slug: post.id },
- props: { post },
- }));
+ return blogEntries.map((post) => {
+ const { prevPost, nextPost } = getPostNavigation(blogEntries, post);
+ return {
+ params: { slug: post.id },
+ props: { post, prevPost, nextPost },
+ };
+ });
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
-const { post } = Astro.props;
+const { post, prevPost, nextPost } = Astro.props;
const { Content } = await render(post);
---
-<PostLayout post={post}>
+<PostLayout post={post} prevPost={prevPost} nextPost={nextPost}>
<Content />
</PostLayout>