diff --git a/src/db/index.ts b/src/db/index.ts index 4cac106..258659b 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,4 +1,5 @@ // Import the database connection +import { db } from './db'; export { db } from './db'; // Export all functions related to database operations diff --git a/src/db/queries.ts b/src/db/queries.ts index 53264da..e4244e7 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -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; @@ -106,4 +122,70 @@ 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(); + + 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) + ); } \ No newline at end of file diff --git a/src/frontend/clientJS/post-archive.ts b/src/frontend/clientJS/post-archive.ts index 512d495..0c582e4 100644 --- a/src/frontend/clientJS/post-archive.ts +++ b/src/frontend/clientJS/post-archive.ts @@ -3,22 +3,245 @@ function setupArchiveToggles() { document.querySelectorAll('.archive-year, .archive-month').forEach(toggle => { toggle.addEventListener('click', function(e) { e.stopPropagation(); - + const toggleIcon = this.querySelector('.archive-toggle'); const content = this.nextElementSibling; - + // 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; + 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(); -} \ No newline at end of file +} + +// 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 +}); \ No newline at end of file diff --git a/src/frontend/components/post-archive.tsx b/src/frontend/components/post-archive.tsx index 4dd07bb..a68ec99 100644 --- a/src/frontend/components/post-archive.tsx +++ b/src/frontend/components/post-archive.tsx @@ -1,92 +1,49 @@ import React from 'react'; import postArchiveScript from '../clientJS/post-archive' with { type: "text" }; +import { getPostsByYearAndMonth } from '../../db'; import { minifyJS } from '../utils'; -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" } - ] - } - ] +// Read posts from database once and store in a closure +const getArchiveData = (() => { + let cachedData: ReturnType | null = null; + + return () => { + if (!cachedData) { + cachedData = getPostsByYearAndMonth(); } - ]; + return cachedData; + }; +})(); + +export function PostArchive() { + const archiveData = getArchiveData(); + return (

Posts

    {archiveData.map((yearData) => (
  • -
    - +
    + {yearData.year} {yearData.count}
    -
    +
      {yearData.months.map((monthData) => (
    • -
      - +
      + {monthData.name} {monthData.count}
      -
      +
      @@ -101,4 +58,4 @@ export function PostArchive() {