Add basic tag support for showing and filtering tagged posts
This commit is contained in:
parent
b0fd1b6d9e
commit
88b6d1bade
@ -96,8 +96,7 @@ Bun.serve({
|
|||||||
// Home page
|
// Home page
|
||||||
"/": (req: Request) => {
|
"/": (req: Request) => {
|
||||||
// Extract URL parameters from the request to pass to the component
|
// Extract URL parameters from the request to pass to the component
|
||||||
const url = new URL(req.url);
|
const searchParams = new URLSearchParams(req.url.split('?')[1]);
|
||||||
const searchParams = Object.fromEntries(url.searchParams.entries());
|
|
||||||
|
|
||||||
if (req.headers.get("shell-loaded") === "true") {
|
if (req.headers.get("shell-loaded") === "true") {
|
||||||
return new Response(renderToString(<Home searchParams={searchParams} />), {
|
return new Response(renderToString(<Home searchParams={searchParams} />), {
|
||||||
|
|||||||
@ -73,11 +73,47 @@ export function addToDatabase(filePath: string, data: { [key: string]: any }, co
|
|||||||
|
|
||||||
// Returns the total number of posts
|
// Returns the total number of posts
|
||||||
|
|
||||||
export function getNumOfPosts() {
|
export function getNumOfPosts(tags: string[] = []) {
|
||||||
const queryCount = db.query('SELECT COUNT(*) AS count FROM posts');
|
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 };
|
const numPosts = queryCount.get() as { count: number };
|
||||||
|
|
||||||
return numPosts.count;
|
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
|
// Helper function to get post data with tags
|
||||||
@ -98,7 +134,11 @@ export function getPostWithTags(postPath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get recent posts
|
// Get recent posts
|
||||||
export function getRecentPosts(limit: number = 10, offset: number = 0) {
|
export function getRecentPosts(limit: number = 10, offset: number = 0, tags: string[] = []) {
|
||||||
|
let posts;
|
||||||
|
|
||||||
|
if (tags.length === 0) {
|
||||||
|
// No tags specified, use the original query
|
||||||
const query = db.query(`
|
const query = db.query(`
|
||||||
SELECT * FROM posts
|
SELECT * FROM posts
|
||||||
WHERE path NOT LIKE '%.md'
|
WHERE path NOT LIKE '%.md'
|
||||||
@ -106,7 +146,28 @@ export function getRecentPosts(limit: number = 10, offset: number = 0) {
|
|||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const posts = query.all(limit, offset) as any[];
|
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
|
// Add tags to each post and clean up paths
|
||||||
return posts.map(post => ({
|
return posts.map(post => ({
|
||||||
|
|||||||
@ -98,5 +98,3 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (document.readyState === 'interactive' || document.readyState === 'complete') {
|
if (document.readyState === 'interactive' || document.readyState === 'complete') {
|
||||||
tagPickerManager.updateTagVisualState();
|
tagPickerManager.updateTagVisualState();
|
||||||
}
|
}
|
||||||
|
|
||||||
export { TagPickerManager };
|
|
||||||
@ -1,21 +1,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import tagPickerScript from '../clientJS/tag-picker.js' with { type: "text" };
|
||||||
import { minifyJS } from '../utils';
|
import { minifyJS } from '../utils';
|
||||||
|
|
||||||
export function TagPicker({ availableTags = [], activeTags = [] }: {
|
import { getAllTags } from '../../db/tags';
|
||||||
availableTags?: { name: string; post_count: number }[],
|
|
||||||
activeTags?: string[]
|
const tags = getAllTags();
|
||||||
}) {
|
|
||||||
|
export function TagPicker() {
|
||||||
return (
|
return (
|
||||||
<div className="tags sheet-background">
|
<div className="tags sheet-background">
|
||||||
<h3>Tags</h3>
|
<h3>Tags</h3>
|
||||||
{availableTags.length > 0 ? (
|
{tags.length > 0 ? (
|
||||||
<ul className="tag-pills">
|
<ul className="tag-pills">
|
||||||
{availableTags.map(tag => (
|
{tags.map(tag => (
|
||||||
<li key={tag.name}>
|
<li key={tag.name}>
|
||||||
<a
|
<a
|
||||||
|
data-tag
|
||||||
href={`?tag=${tag.name.toLowerCase().replace(/\s+/g, '-')}`}
|
href={`?tag=${tag.name.toLowerCase().replace(/\s+/g, '-')}`}
|
||||||
className="tag-pill"
|
className="tag-pill"
|
||||||
title={`${tag.post_count} post${tag.post_count !== 1 ? 's' : ''} (click to toggle)`}
|
title={`${tag.post_count} post${tag.post_count !== 1 ? 's' : ''} (click to view)`}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</a>
|
</a>
|
||||||
@ -30,122 +33,7 @@ export function TagPicker({ availableTags = [], activeTags = [] }: {
|
|||||||
Clear all filters
|
Clear all filters
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<script dangerouslySetInnerHTML={{ __html: minifyJS(tagToggleScript) }} />
|
<script dangerouslySetInnerHTML={{ __html: minifyJS(tagPickerScript) }} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client-side script for tag toggle functionality
|
|
||||||
const tagToggleScript = `
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
class TagPickerManager {
|
|
||||||
constructor() {
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.attachTagClickListeners();
|
|
||||||
this.updateTagVisualState();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse current URL to get active tags
|
|
||||||
getActiveTags() {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
return urlParams.getAll('tag').map(tag => decodeURIComponent(tag).replace(/-/g, ' '));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate query string from tags array
|
|
||||||
generateTagQueryString(tags) {
|
|
||||||
if (tags.length === 0) return '';
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
tags.forEach(tag => {
|
|
||||||
params.append('tag', tag.toLowerCase().replace(/\\s+/g, '-'));
|
|
||||||
});
|
|
||||||
return params.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle a tag and update the page
|
|
||||||
toggleTag(tagName) {
|
|
||||||
const currentTags = this.getActiveTags();
|
|
||||||
|
|
||||||
// Toggle logic: if tag is active, remove it; otherwise add it
|
|
||||||
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}\` : '/';
|
|
||||||
|
|
||||||
window.location.href = newUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach click listeners to tag links
|
|
||||||
attachTagClickListeners() {
|
|
||||||
const tagLinks = document.querySelectorAll('.tag-pill, .post-tag');
|
|
||||||
|
|
||||||
tagLinks.forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const tagName = link.textContent?.trim();
|
|
||||||
if (tagName) {
|
|
||||||
this.toggleTag(tagName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
if (tagName && activeTags.includes(tagName)) {
|
|
||||||
link.classList.add('active');
|
|
||||||
} else {
|
|
||||||
link.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update post tags
|
|
||||||
document.querySelectorAll('.post-tag').forEach(link => {
|
|
||||||
const tagName = link.textContent?.trim();
|
|
||||||
if (tagName && activeTags.includes(tagName)) {
|
|
||||||
link.classList.add('active');
|
|
||||||
} else {
|
|
||||||
link.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update clear filters button visibility
|
|
||||||
const tagActions = document.querySelector('.tag-actions');
|
|
||||||
if (tagActions) {
|
|
||||||
tagActions.style.display = activeTags.length > 0 ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on DOM ready
|
|
||||||
function initializeTagPicker() {
|
|
||||||
// Remove any existing instance
|
|
||||||
if (window.tagPickerManager) {
|
|
||||||
delete window.tagPickerManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new instance
|
|
||||||
window.tagPickerManager = new TagPickerManager();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for DOM to be ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initializeTagPicker);
|
|
||||||
} else {
|
|
||||||
initializeTagPicker();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
`;
|
|
||||||
@ -24,7 +24,6 @@ function attachLinkHandlers() {
|
|||||||
const links: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a:not([data-external])');
|
const links: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a:not([data-external])');
|
||||||
|
|
||||||
links.forEach(link => {
|
links.forEach(link => {
|
||||||
console.log('Attaching listener to:', link.href);
|
|
||||||
link.onclick = async (e) => {
|
link.onclick = async (e) => {
|
||||||
console.log('clicked', link.href);
|
console.log('clicked', link.href);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@ -3,12 +3,16 @@ import { getRecentPosts, formatDate, calculateReadTime, getNumOfPosts } from '..
|
|||||||
import { parseTags } from '../../db/tags';
|
import { parseTags } from '../../db/tags';
|
||||||
import { type BlogPost } from '../../db/queries';
|
import { type BlogPost } from '../../db/queries';
|
||||||
|
|
||||||
export function Home({ searchParams }: { searchParams: Record<string, string> }) {
|
export function Home({ searchParams }: { searchParams: URLSearchParams }) {
|
||||||
const currentPage = parseInt(searchParams.page || "1", 10);
|
|
||||||
const postsPerPage = 10;
|
const postsPerPage = 10;
|
||||||
const totalPages = Math.ceil(getNumOfPosts() / postsPerPage);
|
const tags = searchParams.getAll('tag');
|
||||||
|
|
||||||
|
const currentPage = parseInt(searchParams.get('page') || "1", 10);
|
||||||
|
const totalPages = Math.ceil(getNumOfPosts(tags) / postsPerPage);
|
||||||
const offset = (currentPage - 1) * postsPerPage;
|
const offset = (currentPage - 1) * postsPerPage;
|
||||||
const posts = getRecentPosts(postsPerPage, offset); // Get posts for the current page
|
|
||||||
|
|
||||||
|
const posts = getRecentPosts(postsPerPage, offset, tags); // Get posts for the current page
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@ -569,9 +569,6 @@ h1 {
|
|||||||
background-color: var(--text-primary);
|
background-color: var(--text-primary);
|
||||||
color: var(--bg-primary);
|
color: var(--bg-primary);
|
||||||
border-color: var(--text-primary);
|
border-color: var(--text-primary);
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tag actions for clearing filters */
|
/* Tag actions for clearing filters */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user