Post Archive v1: a first pass at a functional post-archive

This commit is contained in:
Caleb Braaten 2026-01-08 18:31:57 -08:00
parent 1b66fc8a90
commit 960f7ff4d0
6 changed files with 355 additions and 72 deletions

View File

@ -1,4 +1,5 @@
// Import the database connection
import { db } from './db';
export { db } from './db';
// Export all functions related to database operations

View File

@ -1,6 +1,22 @@
import { db } from './db';
import { parseTags } from './tags';
// Interface for archive data structure
export interface ArchiveMonth {
name: string;
count: string;
posts: Array<{
title: string;
href: string;
}>;
}
export interface ArchiveYear {
year: string;
count: string;
months: ArchiveMonth[];
}
// Interface for blog post
export interface BlogPost {
id: number;
@ -107,3 +123,69 @@ export function getPostsByDateRange(startDate: string, endDate: string): BlogPos
return dateQuery.all(startDate, endDate) as BlogPost[];
}
// Function to get posts organized by year and month for the archive
export function getPostsByYearAndMonth(): ArchiveYear[] {
const query = db.query(`
SELECT * FROM posts
WHERE path NOT LIKE '%.md'
ORDER BY date DESC
`);
const posts = query.all() as any[];
// Group posts by year and month
const yearMap = new Map<string, ArchiveYear>();
const monthNames = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
// Process each post
posts.forEach(post => {
const date = new Date(post.date);
const year = String(date.getFullYear());
const monthName = monthNames[date.getMonth()];
// Create clean post href from path
const href = post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '');
// Initialize year if it doesn't exist
if (!yearMap.has(year)) {
yearMap.set(year, {
year,
count: "(0)",
months: []
});
}
const yearData = yearMap.get(year)!;
// Find or create month data
let monthData = yearData.months.find(m => m.name === monthName);
if (!monthData) {
monthData = {
name: monthName,
count: "(0)",
posts: []
};
yearData.months.push(monthData);
}
// Add post to the month
monthData.posts.push({
title: post.title,
href
});
// Update counts
monthData.count = `(${monthData.posts.length})`;
yearData.count = `(${yearData.months.reduce((total, m) => total + m.posts.length, 0)})`;
});
// Convert map to array and sort
return Array.from(yearMap.values()).sort((a, b) =>
parseInt(b.year) - parseInt(a.year)
);
}

View File

@ -10,15 +10,238 @@ function setupArchiveToggles() {
// Toggle expanded state
if (toggleIcon) toggleIcon.classList.toggle('expanded');
if (content) content.classList.toggle('expanded');
// Update aria-expanded
const isExpanded = content.classList.contains('expanded');
this.setAttribute('aria-expanded', String(isExpanded));
// Update tabIndex for nested elements
updateTabIndex(content, isExpanded);
});
});
// Remove active class from all post links
document.querySelectorAll('.archive-post a').forEach(link => {
link.classList.remove('active');
});
// Add click handlers for post links to toggle active class
document.querySelectorAll('.archive-post a').forEach(link => {
link.addEventListener('click', function() {
// Remove active class from all post links
document.querySelectorAll('.archive-post a').forEach(postLink => {
postLink.classList.remove('active');
});
// Add active class to clicked link
this.classList.add('active');
});
});
// Initialize the archive state with a slight delay to ensure DOM is ready
setTimeout(() => {
initializeArchiveState();
// Update the active post indicator after initializing the archive state
updateActivePost();
}, 100);
}
function updateTabIndex(content: Element, isExpanded: boolean) {
if (!content) return;
const tabIndex = isExpanded ? '0' : '-1';
// Update focusable elements based on visibility
content.querySelectorAll('a, button, [tabindex="0"], [tabindex="-1"]').forEach(el => {
el.setAttribute('tabindex', tabIndex);
});
}
function initializeArchiveState() {
// Get the current path
const currentPath = window.location.pathname;
// Check if we're currently viewing a blog post (pattern: /YYYY/MM/slug)
const pathParts = currentPath.split('/').filter(part => part);
const isPostView = pathParts.length >= 3 &&
/^\d{4}$/.test(pathParts[0]) &&
/^\d{1,2}$/.test(pathParts[1]);
// Get all year and month elements
const years = Array.from(document.querySelectorAll('.archive-year')) as HTMLElement[];
const months = Array.from(document.querySelectorAll('.archive-month')) as HTMLElement[];
// Find the target year and month to keep expanded
let targetYear: HTMLElement | null = null;
let targetMonth: HTMLElement | null = null;
if (isPostView) {
// Try to find the post link in multiple ways
let postLink: HTMLAnchorElement | null = null;
// Try to find the post link with exact path or with/without trailing slash
postLink = document.querySelector(`a[href="${currentPath}"]`) as HTMLAnchorElement;
if (!postLink) {
const altPath = currentPath.endsWith('/') ?
currentPath.slice(0, -1) : currentPath + '/';
postLink = document.querySelector(`a[href="${altPath}"]`) as HTMLAnchorElement;
}
// If still not found, try a partial match using the slug
if (!postLink && pathParts.length >= 3) {
const slug = pathParts[pathParts.length - 1];
document.querySelectorAll('.archive-post a').forEach((link) => {
const href = link.getAttribute('href');
if (href && href.endsWith(slug)) {
postLink = link as HTMLAnchorElement;
return;
}
});
}
// If we found the post link, get its parent month and year
if (postLink) {
const postLi = postLink.parentElement; // li.archive-post
const postsUl = postLi?.parentElement; // ul.archive-posts
const contentDiv = postsUl?.parentElement; // div.archive-content
const monthLi = contentDiv?.parentElement; // li containing the month
// Get the month toggle element
const monthElement = monthLi?.querySelector('.archive-month') as HTMLElement;
if (monthElement) {
targetMonth = monthElement;
// Get the parent year
const yearLi = monthLi?.parentElement; // ul containing the month
const yearContentDiv = yearLi?.parentElement; // div.archive-content
const yearMainLi = yearContentDiv?.parentElement; // li containing the year
const yearElement = yearMainLi?.querySelector('.archive-year') as HTMLElement;
targetYear = yearElement;
}
}
}
// If we didn't find a specific target year, fall back to the most recent year
if (!targetYear && years.length > 0) {
targetYear = years[0];
}
// If we didn't find a specific target month, fall back to the most recent month in that year
if (!targetMonth && targetYear) {
const yearContent = targetYear.nextElementSibling as HTMLElement;
if (yearContent) {
const yearMonths = yearContent.querySelectorAll('.archive-month') as NodeListOf<HTMLElement>;
if (yearMonths.length > 0) {
targetMonth = yearMonths[0];
}
}
}
// Helper function to collapse an element
function collapse(element: HTMLElement) {
const toggleIcon = element.querySelector('.archive-toggle');
const content = element.nextElementSibling;
if (toggleIcon) toggleIcon.classList.remove('expanded');
if (content) {
content.classList.remove('expanded');
updateTabIndex(content, false);
}
element.setAttribute('aria-expanded', 'false');
}
// Helper function to expand an element
function expand(element: HTMLElement) {
const toggleIcon = element.querySelector('.archive-toggle');
const content = element.nextElementSibling;
if (toggleIcon) toggleIcon.classList.add('expanded');
if (content) {
content.classList.add('expanded');
updateTabIndex(content, true);
}
element.setAttribute('aria-expanded', 'true');
}
// Collapse all years except our target
years.forEach(year => {
if (year !== targetYear) {
collapse(year);
}
});
// Collapse all months except our target
months.forEach(month => {
if (month !== targetMonth) {
collapse(month);
}
});
// Expand the target year if we have one
if (targetYear) {
expand(targetYear);
}
// Expand the target month if we have one
if (targetMonth) {
expand(targetMonth);
}
}
// Function to update the active post link based on the current URL
function updateActivePost() {
// Remove active class from all post links
document.querySelectorAll('.archive-post a').forEach(link => {
link.classList.remove('active');
});
// Get the current path
const currentPath = window.location.pathname;
// Find the post link matching the current path
let postLink = document.querySelector(`a[href="${currentPath}"]`) as HTMLAnchorElement;
// Try with/without trailing slash if not found
if (!postLink) {
const altPath = currentPath.endsWith('/') ?
currentPath.slice(0, -1) : currentPath + '/';
postLink = document.querySelector(`a[href="${altPath}"]`) as HTMLAnchorElement;
}
// If found, add active class
if (postLink) {
postLink.classList.add('active');
} else {
// Try partial match using the slug
const pathParts = currentPath.split('/').filter(part => part);
if (pathParts.length >= 3) {
const slug = pathParts[pathParts.length - 1];
document.querySelectorAll('.archive-post a').forEach((link) => {
const href = link.getAttribute('href');
if (href && href.endsWith(slug)) {
link.classList.add('active');
}
});
}
}
}
// Initialize on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', () => {
setupArchiveToggles();
});
} else {
setupArchiveToggles();
}
// Listen for browser navigation events (back/forward buttons)
window.addEventListener('popstate', () => {
// Re-initialize the archive state for the new URL
initializeArchiveState();
// Update the active post indicator after initializing the archive state
setTimeout(updateActivePost, 10); // Small delay to ensure DOM is updated
});

View File

@ -1,66 +1,23 @@
import React from 'react';
import postArchiveScript from '../clientJS/post-archive' with { type: "text" };
import { getPostsByYearAndMonth } from '../../db';
import { minifyJS } from '../utils';
// Read posts from database once and store in a closure
const getArchiveData = (() => {
let cachedData: ReturnType<typeof getPostsByYearAndMonth> | null = null;
return () => {
if (!cachedData) {
cachedData = getPostsByYearAndMonth();
}
return cachedData;
};
})();
export function PostArchive() {
const archiveData = [
{
year: "2025",
count: "(8)",
months: [
{
name: "October",
count: "(3)",
posts: [
{ title: "Building a Modern Blog", href: "/blog/post-1" },
{ title: "TypeScript Tips & Tricks", href: "/blog/post-2" },
{ title: "Designing Better UIs", href: "/blog/post-3" }
]
},
{
name: "September",
count: "(5)",
posts: [
{ title: "Getting Started with Bun", href: "/blog/post-4" },
{ title: "Web Performance Optimization", href: "/blog/post-5" },
{ title: "CSS Grid vs Flexbox", href: "/blog/post-6" },
{ title: "JavaScript Best Practices", href: "/blog/post-7" },
{ title: "Accessibility Matters", href: "/blog/post-8" }
]
}
]
},
{
year: "2024",
count: "(12)",
months: [
{
name: "December",
count: "(4)",
posts: [
{ title: "Year in Review 2024", href: "/blog/post-9" },
{ title: "Holiday Coding Projects", href: "/blog/post-10" },
{ title: "New Year Resolutions", href: "/blog/post-11" },
{ title: "Reflecting on Growth", href: "/blog/post-12" }
]
},
{
name: "June",
count: "(8)",
posts: [
{ title: "Summer Tech Trends", href: "/blog/post-13" },
{ title: "Remote Work Tips", href: "/blog/post-14" },
{ title: "Learning Resources", href: "/blog/post-15" },
{ title: "Code Review Best Practices", href: "/blog/post-16" },
{ title: "Git Workflow Tips", href: "/blog/post-17" },
{ title: "Docker for Beginners", href: "/blog/post-18" },
{ title: "API Design Patterns", href: "/blog/post-19" },
{ title: "Testing Strategies", href: "/blog/post-20" }
]
}
]
}
];
const archiveData = getArchiveData();
return (
<div className="postList sheet-background">
@ -68,25 +25,25 @@ export function PostArchive() {
<ul className="post-archive">
{archiveData.map((yearData) => (
<li key={yearData.year}>
<div className="archive-year">
<span className="archive-toggle"></span>
<div className="archive-year" tabIndex={0} role="button" aria-expanded="true">
<span className="archive-toggle expanded"></span>
<span>{yearData.year}</span>
<span className="post-count">{yearData.count}</span>
</div>
<div className="archive-content">
<div className="archive-content expanded">
<ul className="archive-months">
{yearData.months.map((monthData) => (
<li key={monthData.name}>
<div className="archive-month">
<span className="archive-toggle"></span>
<div className="archive-month" tabIndex={0} role="button" aria-expanded="true">
<span className="archive-toggle expanded"></span>
<span>{monthData.name}</span>
<span className="post-count">{monthData.count}</span>
</div>
<div className="archive-content">
<div className="archive-content expanded">
<ul className="archive-posts">
{monthData.posts.map((post) => (
<li key={post.href} className="archive-post">
<a href={post.href}>{post.title}</a>
<a href={post.href} tabIndex={-1}>{post.title}</a>
</li>
))}
</ul>

View File

@ -5,8 +5,10 @@ import { type BlogPost } from '../../db/queries';
export function Home({ searchParams }: { searchParams: Record<string, string> }) {
const currentPage = parseInt(searchParams.page || "1", 10);
const totalPages = Math.ceil(getNumOfPosts() / 10);
const posts = getRecentPosts(10, ); // Get the 10 most recent posts
const postsPerPage = 10;
const totalPages = Math.ceil(getNumOfPosts() / postsPerPage);
const offset = (currentPage - 1) * postsPerPage;
const posts = getRecentPosts(postsPerPage, offset); // Get posts for the current page
return (
<main>

View File

@ -628,11 +628,23 @@ h1 {
user-select: none;
}
.archive-year.expanded,
.archive-month.expanded {
background-color: var(--bg-primary);
font-weight: 600;
}
.archive-year:hover,
.archive-month:hover {
background-color: var(--bg-primary);
}
.archive-year:focus,
.archive-month:focus {
outline: 2px solid var(--accent-color);
outline-offset: 1px;
}
.archive-year {
font-weight: 600;
font-size: 14px;
@ -652,6 +664,7 @@ h1 {
.archive-toggle.expanded {
transform: rotate(90deg);
font-weight: bold;
}
.archive-content {
@ -691,6 +704,11 @@ h1 {
color: var(--text-primary);
}
.archive-post a.active {
color: var(--text-primary);
text-decoration: underline;
}
.post-count {
font-size: 12px;
color: var(--text-secondary);