From 88b6d1bade415fe326a2d46804886c88f3df4ce2 Mon Sep 17 00:00:00 2001 From: Caleb Braaten Date: Mon, 12 Jan 2026 13:49:23 -0800 Subject: [PATCH] Add basic tag support for showing and filtering tagged posts --- index.tsx | 9 +- src/db/posts.ts | 93 ++++++++++++++--- src/frontend/clientJS/tag-picker.ts | 12 +-- src/frontend/components/tag-picker.tsx | 136 +++---------------------- src/frontend/onLoad.ts | 1 - src/frontend/pages/home.tsx | 12 ++- src/frontend/styles.css | 9 +- 7 files changed, 109 insertions(+), 163 deletions(-) diff --git a/index.tsx b/index.tsx index 02dbb97..7bfa6e9 100644 --- a/index.tsx +++ b/index.tsx @@ -58,9 +58,9 @@ async function blogPosts(hmr: boolean) { return new Response( renderToString( - , ), @@ -96,8 +96,7 @@ Bun.serve({ // Home page "/": (req: Request) => { // Extract URL parameters from the request to pass to the component - const url = new URL(req.url); - const searchParams = Object.fromEntries(url.searchParams.entries()); + const searchParams = new URLSearchParams(req.url.split('?')[1]); if (req.headers.get("shell-loaded") === "true") { return new Response(renderToString(), { diff --git a/src/db/posts.ts b/src/db/posts.ts index bc80a64..20e3d6f 100644 --- a/src/db/posts.ts +++ b/src/db/posts.ts @@ -73,11 +73,47 @@ export function addToDatabase(filePath: string, data: { [key: string]: any }, co // Returns the total number of posts -export function getNumOfPosts() { - const queryCount = db.query('SELECT COUNT(*) AS count FROM posts'); - const numPosts = queryCount.get() as { count: number }; - - return numPosts.count; +export function getNumOfPosts(tags: string[] = []) { + if (tags.length === 0) { + const queryCount = db.query('SELECT COUNT(*) AS count FROM posts WHERE path NOT LIKE \'%.md\''); + const numPosts = queryCount.get() as { count: number }; + return numPosts.count; + } else { + // Filter by tags - convert URL-format tags (lowercase with hyphens) + // to database format (lowercase with spaces) for case-insensitive comparison + const placeholders = tags.map(() => '?').join(','); + const databaseFormatTags = tags.map(tag => tag.toLowerCase().replace(/-/g, ' ')); + + const queryCount = db.query(` + SELECT COUNT(DISTINCT p.id) AS count FROM posts p + JOIN post_tags pt ON p.id = pt.post_id + JOIN tags t ON pt.tag_id = t.id + WHERE LOWER(t.name) IN (${placeholders}) + AND p.path NOT LIKE '%.md' + `); + + // Get all posts that match the tags + const matchedPosts = queryCount.all(...databaseFormatTags) as { count: number }[]; + + if (matchedPosts.length === 0) return 0; + + // Now count how many posts have ALL the specified tags + const countQuery = db.query(` + SELECT COUNT(*) AS count FROM ( + SELECT p.id, COUNT(DISTINCT t.id) as tag_count + FROM posts p + JOIN post_tags pt ON p.id = pt.post_id + JOIN tags t ON pt.tag_id = t.id + WHERE LOWER(t.name) IN (${placeholders}) + AND p.path NOT LIKE '%.md' + GROUP BY p.id + HAVING COUNT(DISTINCT t.id) = ? + ) + `); + + const result = countQuery.get(...databaseFormatTags, tags.length) as { count: number }; + return result.count; + } } // Helper function to get post data with tags @@ -98,15 +134,40 @@ export function getPostWithTags(postPath: string) { } // Get recent posts -export function getRecentPosts(limit: number = 10, offset: number = 0) { - const query = db.query(` - SELECT * FROM posts - WHERE path NOT LIKE '%.md' - ORDER BY date DESC - LIMIT ? OFFSET ? - `); +export function getRecentPosts(limit: number = 10, offset: number = 0, tags: string[] = []) { + let posts; - const posts = query.all(limit, offset) as any[]; + if (tags.length === 0) { + // No tags specified, use the original query + const query = db.query(` + SELECT * FROM posts + WHERE path NOT LIKE '%.md' + ORDER BY date DESC + LIMIT ? OFFSET ? + `); + + posts = query.all(limit, offset) as any[]; + } else { + // Filter by tags - join directly with post_tags and tags + // and ensure posts have ALL the specified tags + const placeholders = tags.map(() => '?').join(','); + const tagFilter = db.query(` + SELECT p.* FROM posts p + JOIN post_tags pt ON p.id = pt.post_id + JOIN tags t ON pt.tag_id = t.id + WHERE LOWER(t.name) IN (${placeholders}) + AND p.path NOT LIKE '%.md' + GROUP BY p.id + HAVING COUNT(DISTINCT t.id) = ? + ORDER BY p.date DESC + LIMIT ? OFFSET ? + `); + + // Convert URL-format tags (lowercase with hyphens) to database format (lowercase with spaces) + // and then use that for case-insensitive comparison + const databaseFormatTags = tags.map(tag => tag.toLowerCase().replace(/-/g, ' ')); + posts = tagFilter.all(...databaseFormatTags, tags.length, limit, offset) as any[]; + } // Add tags to each post and clean up paths return posts.map(post => ({ @@ -159,7 +220,7 @@ export function calculateReadTime(content: string): number { export function formatDate(dateString: string): string { // Parse ISO date string (YYYY-MM-DD) const date = new Date(dateString + 'T00:00:00'); - + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', @@ -177,10 +238,10 @@ export function getAdjacentPosts(currentPostPath: string) { `); 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 }; diff --git a/src/frontend/clientJS/tag-picker.ts b/src/frontend/clientJS/tag-picker.ts index 83dcae8..4daa924 100644 --- a/src/frontend/clientJS/tag-picker.ts +++ b/src/frontend/clientJS/tag-picker.ts @@ -27,12 +27,12 @@ class TagPickerManager { // Toggle a tag and update the page toggleTag(tagName: string) { const currentTags = this.getActiveTags(); - + // Toggle logic: if tag is active, remove it; otherwise add it - const newTags = currentTags.includes(tagName) + const newTags = currentTags.includes(tagName) ? currentTags.filter(t => t !== tagName) : [...currentTags, tagName]; - + // Navigate to new URL const queryString = this.generateTagQueryString(newTags); const newUrl = queryString ? `/?${queryString}` : '/'; @@ -42,7 +42,7 @@ class TagPickerManager { // Attach click listeners to tag links attachTagClickListeners() { const tagLinks = document.querySelectorAll('.tag-pill, .post-tag'); - + tagLinks.forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); @@ -57,7 +57,7 @@ class TagPickerManager { // Update visual state of tags based on current URL updateTagVisualState() { const activeTags = this.getActiveTags(); - + // Update tag pills in sidebar document.querySelectorAll('.tag-pill').forEach(link => { const tagName = link.textContent?.trim(); @@ -98,5 +98,3 @@ document.addEventListener('DOMContentLoaded', () => { if (document.readyState === 'interactive' || document.readyState === 'complete') { tagPickerManager.updateTagVisualState(); } - -export { TagPickerManager }; \ No newline at end of file diff --git a/src/frontend/components/tag-picker.tsx b/src/frontend/components/tag-picker.tsx index c1b628b..884e0ed 100644 --- a/src/frontend/components/tag-picker.tsx +++ b/src/frontend/components/tag-picker.tsx @@ -1,21 +1,24 @@ import React from 'react'; +import tagPickerScript from '../clientJS/tag-picker.js' with { type: "text" }; import { minifyJS } from '../utils'; -export function TagPicker({ availableTags = [], activeTags = [] }: { - availableTags?: { name: string; post_count: number }[], - activeTags?: string[] -}) { +import { getAllTags } from '../../db/tags'; + +const tags = getAllTags(); + +export function TagPicker() { return (

Tags

- {availableTags.length > 0 ? ( + {tags.length > 0 ? (
    - {availableTags.map(tag => ( + {tags.map(tag => (
  • - {tag.name} @@ -30,122 +33,7 @@ export function TagPicker({ availableTags = [], activeTags = [] }: { Clear all filters
-