Add next/prev links to bottom of blog posts

This commit is contained in:
Caleb Braaten 2026-01-09 00:13:23 -08:00
parent cc21c06641
commit 58fa014341
4 changed files with 151 additions and 6 deletions

View File

@ -6,7 +6,7 @@ import { Home } from "./src/frontend/pages/home";
import { NotFound } from "./src/frontend/pages/not-found";
import demo from "./temp/appshell.html";
import { Post } from "./src/frontend/pages/post";
import { getPostWithTags } from "./src/db/index";
import { getPostWithTags, getAdjacentPosts } from "./src/db/index";
async function blogPosts(hmr: boolean) {
const glob = new Bun.Glob("**/*.md");
@ -29,12 +29,17 @@ async function blogPosts(hmr: boolean) {
headers: { "Content-Type": "text/html" },
});
// Get adjacent posts for navigation
const { previousPost, nextPost } = getAdjacentPosts(post.path);
const data = {
title: post.title,
summary: post.summary,
date: new Date(post.date),
readingTime: post.reading_time,
tags: post.tags || [],
previousPost,
nextPost,
};
// AppShell is already loaded, just send the <main> content
@ -53,7 +58,10 @@ async function blogPosts(hmr: boolean) {
return new Response(
renderToString(
<AppShell>
<Post meta={data} children={post.content} />
<Post
meta={data}
children={post.content}
/>
</AppShell>,
),
{

View File

@ -163,3 +163,49 @@ export function formatDate(dateString: string): string {
day: 'numeric'
});
}
// Get adjacent posts (previous and next) chronologically for a given post
export function getAdjacentPosts(currentPostPath: string) {
const allPostsQuery = db.query(`
SELECT path, title, date
FROM posts
WHERE path NOT LIKE '%.md'
ORDER BY date ASC
`);
const allPosts = allPostsQuery.all() as any[];
// Find the current post index
const currentIndex = allPosts.findIndex(post => post.path === currentPostPath);
// If not found, return empty navigation
if (currentIndex === -1) {
return { previousPost: null, nextPost: null };
}
// Get previous post (newer date)
let previousPost = null;
if (currentIndex < allPosts.length - 1) {
const prevPost = allPosts[currentIndex + 1];
// Clean up the path to match the URL structure
const cleanPath = prevPost.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '');
previousPost = {
title: prevPost.title,
path: cleanPath
};
}
// Get next post (older date)
let nextPost = null;
if (currentIndex > 0) {
const post = allPosts[currentIndex - 1];
// Clean up the path to match the URL structure
const cleanPath = post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '');
nextPost = {
title: post.title,
path: cleanPath
};
}
return { previousPost, nextPost };
}

View File

@ -1,5 +1,10 @@
import React from 'react';
interface NavigationPost {
title: string;
path: string;
}
interface PostProps {
// HTML string for the blog post body
children: string;
@ -7,10 +12,14 @@ interface PostProps {
title: string;
date: Date;
readingTime: string;
previousPost?: NavigationPost | null;
nextPost?: NavigationPost | null;
};
}
export function Post({ children, meta }: PostProps) {
const { previousPost, nextPost } = meta;
return (
<main>
<article className="blog-post">
@ -39,6 +48,22 @@ export function Post({ children, meta }: PostProps) {
</div>
</header>
<div className="post-content" dangerouslySetInnerHTML={{ __html: children }} />
<footer className="post-navigation">
<div className="post-nav-links">
{previousPost && (
<a href={previousPost.path} className="post-nav-link prev-nav">
<span className="nav-direction"> Previous</span>
<span className="nav-title">{previousPost.title}</span>
</a>
)}
{nextPost && (
<a href={nextPost.path} className="post-nav-link next-nav">
<span className="nav-direction">Next </span>
<span className="nav-title">{nextPost.title}</span>
</a>
)}
</div>
</footer>
</article>
</main>
)

View File

@ -914,6 +914,64 @@ h1 {
margin: 0;
}
.post-navigation {
margin-top: 48px;
padding-top: 24px;
border-top: 1px solid var(--border-color);
}
.post-nav-links {
display: flex;
justify-content: space-between;
gap: 16px;
}
.post-nav-link {
display: flex;
flex-direction: column;
padding: 12px 16px;
border-radius: 6px;
background-color: var(--sheet-background);
border: 1px solid var(--border-color);
text-decoration: none;
color: var(--text-primary);
transition: all 0.2s ease;
flex: 1;
max-width: 48%;
}
.post-nav-link:hover {
background-color: var(--accent-color);
color: var(--accent-text-color);
transform: translateY(-2px);
}
.prev-nav {
align-items: flex-start;
}
.next-nav {
align-items: flex-end;
text-align: right;
}
.nav-direction {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 4px;
color: var(--accent-color);
}
.post-nav-link:hover .nav-direction {
color: var(--accent-text-color);
}
.nav-title {
font-size: 1rem;
font-weight: 500;
line-height: 1.4;
}
/* Responsive adjustments */
@media (max-width: 1200px) {
aside {
@ -1025,16 +1083,24 @@ h1 {
}
.post-content h3 {
font-size: 20px;
font-size: 22px;
}
.post-nav-link {
padding: 10px 12px;
font-size: 0.9rem;
}
.nav-title {
font-size: 0.9rem;
}
.tag-pills {
gap: 6px;
display: flex;
}
.tag-pill {
font-size: 12px;
padding: 4px 10px;
padding: 6px 12px;
}
}