Compare commits

...

11 Commits

21 changed files with 983 additions and 798 deletions

View File

@@ -4,7 +4,7 @@ import { renderToString } from "react-dom/server";
import matter from 'gray-matter'; import matter from 'gray-matter';
import { marked } from 'marked'; import { marked } from 'marked';
import { addToDatabase } from "../src/db/index"; import { dbConnection } from "../src/db/index";
import { AppShell } from "../src/frontend/AppShell"; import { AppShell } from "../src/frontend/AppShell";
import { Post } from "../src/frontend/pages/post"; import { Post } from "../src/frontend/pages/post";
@@ -38,7 +38,7 @@ const markdownLoader: BunPlugin = {
readingTime: data.readingTime || `${Math.ceil(content.split(/\s+/).length / 200)} min read` readingTime: data.readingTime || `${Math.ceil(content.split(/\s+/).length / 200)} min read`
}; };
const renderedHtml = renderToString(<AppShell><Post meta={meta} children={bodyHtml} /></AppShell>); const renderedHtml = renderToString(<AppShell><Post meta={meta} children={bodyHtml} /></AppShell>);
addToDatabase(args.path, meta, bodyHtml); // Load the post to the database for dynamic querying dbConnection.addPost(args.path, meta, bodyHtml); // Load the post to the database for dynamic querying
// JSX Approach // JSX Approach
return { return {

View File

@@ -1,6 +1,6 @@
import matter from 'gray-matter'; import matter from 'gray-matter';
import { marked } from 'marked'; import { marked } from 'marked';
import { addToDatabase } from "../src/db/index"; import { dbConnection } from "../src/db";
// When the server starts, import all the blog post metadata into the database // When the server starts, import all the blog post metadata into the database
// Executed on startup because it's included in <root> ./bunfig.toml // Executed on startup because it's included in <root> ./bunfig.toml
@@ -22,7 +22,7 @@ import { addToDatabase } from "../src/db/index";
} }
const bodyHtml = await marked.parse(processedContent); const bodyHtml = await marked.parse(processedContent);
addToDatabase(route, data, bodyHtml); dbConnection.addPost(route, data, bodyHtml);
} }
console.log('Posts have been imported into db'); console.log('Posts have been imported into db');

View File

@@ -6,7 +6,7 @@ import { Home } from "./src/frontend/pages/home";
import { NotFound } from "./src/frontend/pages/not-found"; import { NotFound } from "./src/frontend/pages/not-found";
import demo from "./temp/appshell.html"; import demo from "./temp/appshell.html";
import { Post } from "./src/frontend/pages/post"; import { Post } from "./src/frontend/pages/post";
import { getPostWithTags } from "./src/db/index"; import { dbConnection } from "./src/db";
async function blogPosts(hmr: boolean) { async function blogPosts(hmr: boolean) {
const glob = new Bun.Glob("**/*.md"); const glob = new Bun.Glob("**/*.md");
@@ -14,27 +14,33 @@ async function blogPosts(hmr: boolean) {
for await (const file of glob.scan("./content")) { for await (const file of glob.scan("./content")) {
const post = await import(`./content/${file}`, { with: { type: "html" } }); const post = await import(`./content/${file}`, { with: { type: "html" } });
const route = `/${file.replace(/\.md$/, "")}`; const route = `/${file.replace(/\.md$/, "")}`;
dbConnection.getAllTags();
if (hmr) { if (hmr) {
// Use Bun Importer plugin for hot reloading in the browser // Use Bun Importer plugin for hot reloading in the browser
blogPosts[`/hmr${route}`] = post.default; blogPosts[`/hmr${route}`] = post.default;
} else { } else {
// Use the Database for sending just the HTML or the HTML and AppShell // Use the Database for sending just the HTML or the HTML and AppShell
blogPosts[route] = (req: Request) => { blogPosts[route] = async (req: Request) => {
const path = new URL(req.url).pathname; const path = new URL(req.url).pathname;
const post = getPostWithTags(path); const post = dbConnection.getPost(path);
if (!post) if (!post)
return new Response(renderToString(<NotFound />), { return new Response(renderToString(<NotFound />), {
status: 404, status: 404,
headers: { "Content-Type": "text/html" }, headers: { "Content-Type": "text/html" },
}); });
// Get adjacent posts for navigation
const { previousPost, nextPost } = dbConnection.getAdjacentPosts(post.path);
const data = { const data = {
title: post.title, title: post.title,
summary: post.summary, summary: post.summary,
date: new Date(post.date), date: new Date(post.date),
readingTime: post.reading_time, readingTime: post.reading_time,
tags: post.tags || [], tags: post.tags || [],
previousPost,
nextPost,
}; };
// AppShell is already loaded, just send the <main> content // AppShell is already loaded, just send the <main> content
@@ -53,7 +59,10 @@ async function blogPosts(hmr: boolean) {
return new Response( return new Response(
renderToString( renderToString(
<AppShell> <AppShell>
<Post meta={data} children={post.content} /> <Post
meta={data}
children={post.content}
/>
</AppShell>, </AppShell>,
), ),
{ {
@@ -83,13 +92,12 @@ Bun.serve({
...(await blogPosts(false)), ...(await blogPosts(false)),
// hot module replacement in development mode // hot module replacement in development mode
...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : []), ...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : {}),
// 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} />), {
@@ -101,7 +109,7 @@ Bun.serve({
return new Response( return new Response(
renderToString( renderToString(
<AppShell> <AppShell searchParams={searchParams}>
<Home searchParams={searchParams} /> <Home searchParams={searchParams} />
</AppShell>, </AppShell>,
), ),

View File

@@ -1,66 +0,0 @@
import { Database } from 'bun:sqlite';
import path from 'path';
// Singleton database connection
class DatabaseConnection {
private static instance: DatabaseConnection;
private db: Database;
private constructor() {
// Initialize the database if it doesn't exist
const dbPath = path.join(process.cwd(), 'blog.sqlite');
this.db = new Database(dbPath);
// Initialize database schema
this.initializeDatabase();
}
public static getInstance(): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
public getDatabase(): Database {
return this.db;
}
private initializeDatabase() {
// Create the posts table if it doesn't exist
this.db.run(`
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT UNIQUE NOT NULL,
title TEXT,
date TEXT,
reading_time TEXT,
summary TEXT,
content TEXT
)
`);
// Create the tags table if it doesn't exist
this.db.run(`
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
)
`);
// Create the post_tags junction table for many-to-many relationship
this.db.run(`
CREATE TABLE IF NOT EXISTS post_tags (
post_id INTEGER,
tag_id INTEGER,
PRIMARY KEY (post_id, tag_id),
FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE
)
`);
}
}
// Get the singleton database instance and export it
const dbConnection = DatabaseConnection.getInstance();
export const db = dbConnection.getDatabase();

View File

@@ -1,8 +1,425 @@
// Import the database connection import { Database } from 'bun:sqlite';
import { db } from './db'; import path from 'path';
export { db } from './db';
// Export all functions related to database operations /**
export * from './posts'; * Singleton database connection class for managing blog posts and tags
export * from './tags'; */
export * from './queries'; class DatabaseConnection {
private static instance: DatabaseConnection;
private db: Database;
/**
* Private constructor to initialize database connection and schema
*/
private constructor() {
// Initialize the database if it doesn't exist
const dbPath = path.join(process.cwd(), 'blog.sqlite');
this.db = new Database(dbPath);
// Initialize database schema
this.initializeDatabase();
}
/**
* Gets the singleton instance of the DatabaseConnection
*
* @returns The DatabaseConnection singleton instance
*/
public static getInstance(): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
/**
* Gets the database connection
*
* @returns The SQLite database instance
*/
public getDatabase(): Database {
return this.db;
}
/**
* Initializes the database schema with posts, tags, and post_tags tables
*/
private initializeDatabase() {
// Create the posts table if it doesn't exist
this.db.run(`
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT UNIQUE NOT NULL,
title TEXT,
date TEXT,
reading_time TEXT,
summary TEXT,
content TEXT
)
`);
// Create the tags table if it doesn't exist
this.db.run(`
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
url_normalized TEXT GENERATED ALWAYS AS (lower(replace(name, ' ', '-'))) STORED
)
`);
// Create the post_tags junction table for many-to-many relationship
this.db.run(`
CREATE TABLE IF NOT EXISTS post_tags (
post_id INTEGER,
tag_id INTEGER,
PRIMARY KEY (post_id, tag_id),
FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE
)
`);
}
/**
* Adds a post to the database with its metadata and content
*
* @param filePath - The path to the post file
* @param data - An object containing the post metadata (title, date, tags, etc.)
* @param content - The full content of the post
*/
public addPost(filePath: string, data: { [key: string]: any }, content: string) {
if (!data) return;
// Use a transaction to ensure data consistency
const transaction = this.db.transaction(() => {
try {
// Extract common fields
const { title, date, readingTime, tags, excerpt } = data;
// Convert all values to strings or null explicitly (except tags)
// Ensure date is stored in ISO format for consistent sorting
const values = [
filePath ? String(filePath) : null,
title ? String(title) : null,
date ? (date instanceof Date ? date.toISOString().split('T')[0] : String(date)) : null,
readingTime ? String(readingTime) : null,
excerpt ? String(excerpt) : null,
content ? String(content) : null
];
// Query to insert or replace metadata (without tags)
const insertPost = this.db.query(`
INSERT OR REPLACE INTO posts
(path, title, date, reading_time, summary, content)
VALUES (?, ?, ?, ?, ?, ?)
`);
insertPost.run(...values);
// Get the post ID
const getPostId = this.db.query('SELECT id FROM posts WHERE path = ?');
const postResult = getPostId.get(filePath) as { id: number } | undefined;
if (!postResult) {
throw new Error(`Failed to retrieve post ID for ${filePath}`);
}
// Delete existing tag associations for this post
const deleteExistingTags = this.db.query('DELETE FROM post_tags WHERE post_id = ?');
deleteExistingTags.run(postResult.id);
// If tags exist, process them
if (tags && Array.isArray(tags)) {
// Insert into junction table
const insertPostTag = this.db.query('INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)');
// Create or get tag and associate with post
const createOrGetTag = this.db.query(`
INSERT OR IGNORE INTO tags (name) VALUES (?)
RETURNING id
`);
const getTag = this.db.query('SELECT id FROM tags WHERE name = ?');
for (const tag of tags) {
let tagResult = createOrGetTag.get(String(tag)) as { id: number } | undefined;
// If no result from insert, the tag already exists, so get it
if (!tagResult) {
tagResult = getTag.get(String(tag)) as { id: number } | undefined;
}
if (!tagResult) {
throw new Error(`Failed to retrieve tag ID for ${tag}`);
}
insertPostTag.run(postResult.id, tagResult.id);
}
}
} catch (error) {
console.error(`Failed to store ${filePath}:`, error);
throw error; // Re-throw to make the transaction fail
}
});
// Execute the transaction
try {
transaction();
} catch (error) {
console.error(`Transaction failed for ${filePath}:`, error);
}
}
/**
* Gets the total number of posts in the database
*
* @param tags - Optional array of tag names to filter posts by
* @returns The number of posts, optionally filtered by tags
*/
public getNumOfPosts(tags: string[] = []) {
if (tags.length === 0) {
const queryCount = this.db.query('SELECT COUNT(*) AS count FROM posts WHERE path NOT LIKE \'%.md\'');
const numPosts = queryCount.get() as { count: number };
return numPosts.count;
} else {
// Use url_normalized field for tag filtering
const placeholders = tags.map(() => '?').join(',');
// Get count of posts that have ALL the specified tags using url_normalized
const countQuery = this.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 t.url_normalized IN (${placeholders})
AND p.path NOT LIKE '%.md'
GROUP BY p.id
HAVING COUNT(DISTINCT t.id) = ?
)
`);
const result = countQuery.get(...tags, tags.length) as { count: number };
return result.count;
}
}
/**
* Retrieves a paginated list of posts
*
* @param limit - Maximum number of posts to return (default: 10)
* @param offset - Number of posts to skip for pagination (default: 0)
* @param tags - Optional array of tag names to filter posts by
* @returns Array of posts with metadata and cleaned paths
*/
public getPosts(limit: number = 10, offset: number = 0, tags: string[] = []):
Array<{
id: number,
path: string,
title: string,
date: string,
readingTime: string,
summary: string,
content: string,
tags: string[],
}>
{
let posts;
if (tags.length === 0) {
// No tags specified, use the original query
const query = this.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 using url_normalized field
// and ensure posts have ALL the specified tags
const placeholders = tags.map(() => '?').join(',');
const tagFilter = this.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 t.url_normalized IN (${placeholders})
AND p.path NOT LIKE '%.md'
GROUP BY p.id
HAVING COUNT(DISTINCT t.id) = ?
ORDER BY p.date DESC
LIMIT ? OFFSET ?
`);
// Use the tags directly since we're now comparing with url_normalized
posts = tagFilter.all(...tags, tags.length, limit, offset) as any[];
}
// Helper function to get tags for a post
const getPostTags = (postId: number): string[] => {
const query = this.db.query(`
SELECT t.name FROM tags t
JOIN post_tags pt ON t.id = pt.tag_id
WHERE pt.post_id = ?
`);
const results = query.all(postId) as { name: string }[];
return results.map(row => row.name);
};
// Add tags to each post and clean up paths
return posts.map(post => {
const basePost = post satisfies {
id: number,
path: string,
title: string,
date: string,
readingTime: string,
summary: string,
content: string
};
return {
...basePost,
tags: getPostTags(post.id),
path: post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '')
};
});
}
/**
* Retrieves a single post by its file path
*
* @param path - The file path of the post to retrieve
* @returns The post object with metadata and tags, or null if not found
*/
public getPost(path: string) {
const query = this.db.query(`
SELECT * FROM posts
WHERE path = ?
`);
const post = query.get(path) as any;
if (!post) return null;
// Helper function to get tags for a post
const getPostTags = (postId: number): string[] => {
const query = this.db.query(`
SELECT t.name FROM tags t
JOIN post_tags pt ON t.id = pt.tag_id
WHERE pt.post_id = ?
`);
const results = query.all(postId) as { name: string }[];
return results.map(row => row.name);
};
// Get tags for this post
post.tags = getPostTags(post.id);
return post;
}
/**
* Finds the chronologically adjacent posts (previous and next) for navigation
*
* @param currentPostPath - The file path of the current post
* @returns Object with previousPost and nextPost properties, each containing title and path, or null if no adjacent post exists
*/
public getAdjacentPosts(currentPostPath: string) {
const allPostsQuery = this.db.query(`
SELECT path, title, date
FROM posts
WHERE path NOT LIKE '%.md'
ORDER BY date DESC
`);
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 post, which comes before current in reverse chronological order)
let previousPost = null;
if (currentIndex > 0) {
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 post, which comes after current in reverse chronological order)
let nextPost = null;
if (currentIndex < allPosts.length - 1) {
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 };
}
/**
* Retrieves all unique tags from the database with their respective post counts.
*
* This method implements caching to avoid repeated database queries. After the first
* call, subsequent calls will return the cached results. Be aware that on db build, this
* likely won't be accurate because of the natural race condition and the server should be restarted.
*
* @returns {Array<{ name: string; post_count: number; urlNormalized: string }>}
* An array of tag objects containing:
* - name: The display name of the tag
* - post_count: Number of posts associated with this tag
* - urlNormalized: The URL-safe version of the tag name
*/
public getAllTags(): { name: string; post_count: number; urlNormalized: string }[] {
if (!this.getAllTagsCache) {
this.getAllTagsCache = this.initGetAllTags();
}
return this.getAllTagsCache;
}
private getAllTagsCache: { name: string; post_count: number; urlNormalized: string }[] | null = null
private initGetAllTags() {
const query = db.query(`
SELECT t.name, t.url_normalized, COUNT(pt.post_id) as post_count
FROM tags t
JOIN post_tags pt ON t.id = pt.tag_id
JOIN posts p ON pt.post_id = p.id
GROUP BY t.id, t.name, t.url_normalized
ORDER BY post_count DESC, t.name ASC
`);
const results = query.all() as { name: string; url_normalized: string; post_count: number }[];
return results.map(row => ({
name: row.name,
urlNormalized: row.url_normalized,
post_count: row.post_count
}));
}
}
/**
* The SQLite database connection class
* Use this by default for using helper query functions
*/
export const dbConnection = DatabaseConnection.getInstance();
/**
* The SQLite database connection instance
* Use this exported instance for direct database operations
*/
export const db = dbConnection.getDatabase();

View File

@@ -1,165 +0,0 @@
import { db } from './db';
import { getOrCreateTag } from './tags';
import { getPostTags } from './tags';
// Load the blog post into a SQLite database
// This allows us to index and make queries against the metadata
// to support functions like search, filtering, and sorting
// as well as return either the post or the full AppShell with the post content
export function addToDatabase(filePath: string, data: { [key: string]: any }, content: string) {
if (!data) return;
// Use a transaction to ensure data consistency
const transaction = db.transaction(() => {
try {
// Extract common fields
const { title, date, readingTime, tags, excerpt } = data;
// Convert all values to strings or null explicitly (except tags)
const values = [
filePath ? String(filePath) : null,
title ? String(title) : null,
date ? String(date) : null,
readingTime ? String(readingTime) : null,
excerpt ? String(excerpt) : null,
content ? String(content) : null
];
// Query to insert or replace metadata (without tags)
const insertPost = db.query(`
INSERT OR REPLACE INTO posts
(path, title, date, reading_time, summary, content)
VALUES (?, ?, ?, ?, ?, ?)
`);
insertPost.run(...values);
// Get the post ID
const getPostId = db.query('SELECT id FROM posts WHERE path = ?');
const postResult = getPostId.get(filePath) as { id: number } | undefined;
if (!postResult) {
throw new Error(`Failed to retrieve post ID for ${filePath}`);
}
// Delete existing tag associations for this post
const deleteExistingTags = db.query('DELETE FROM post_tags WHERE post_id = ?');
deleteExistingTags.run(postResult.id);
// If tags exist, process them
if (tags && Array.isArray(tags)) {
// Insert into junction table
const insertPostTag = db.query('INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)');
for (const tag of tags) {
const tagId = getOrCreateTag(String(tag));
insertPostTag.run(postResult.id, tagId);
}
}
} catch (error) {
console.error(`Failed to store ${filePath}:`, error);
throw error; // Re-throw to make the transaction fail
}
});
// Execute the transaction
try {
transaction();
} catch (error) {
console.error(`Transaction failed for ${filePath}:`, error);
}
}
// 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;
}
// Helper function to get post data with tags
export function getPostWithTags(postPath: string) {
const getPost = db.query(`
SELECT * FROM posts WHERE path = ?
`);
const post = getPost.get(postPath) as any;
if (!post) return null;
// Get tags for this post
if (post.id) {
post.tags = getPostTags(post.id);
}
return post;
}
// 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 ?
`);
const posts = query.all(limit, offset) as any[];
// Add tags to each post and clean up paths
return posts.map(post => ({
...post,
tags: getPostTags(post.id),
path: post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '')
}));
}
// Get all posts
export function getAllPosts() {
const query = db.query(`
SELECT * FROM posts
ORDER BY date DESC
`);
const posts = query.all() as any[];
// Add tags to each post
return posts.map(post => ({
...post,
tags: getPostTags(post.id)
}));
}
// Helper function to get a post by its path
export function getPostByPath(path: string) {
const query = db.query(`
SELECT * FROM posts
WHERE path = ?
`);
const post = query.get(path) as any;
if (!post) return null;
// Get tags for this post
post.tags = getPostTags(post.id);
return post;
}
// Helper function to calculate read time
export function calculateReadTime(content: string): number {
const wordsPerMinute = 200;
const words = content.split(/\s+/).length;
return Math.max(1, Math.ceil(words / wordsPerMinute));
}
// Helper function to format date for display
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}

View File

@@ -1,191 +0,0 @@
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;
path: string;
title: string;
date: string;
author?: string;
tags: string | string[];
summary?: string;
reading_time?: string;
content?: string;
}
// Get posts by a single tag
export function getPostsByTag(tag: string): BlogPost[] {
const query = 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 t.name = ?
ORDER BY p.date DESC
`);
return query.all(tag) as BlogPost[];
}
// Get posts by multiple tags (AND logic - posts must contain ALL tags)
export function getPostsByTags(tags: string[]): BlogPost[] {
if (tags.length === 0) {
return getRecentPosts();
}
// Build query for multiple tags using JOIN and GROUP BY
const placeholders = tags.map(() => '?').join(',');
const query = 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 t.name IN (${placeholders})
AND p.path NOT LIKE '%.md'
GROUP BY p.id
HAVING COUNT(DISTINCT t.id) = ?
ORDER BY p.date DESC
`);
return query.all(...tags, tags.length) as BlogPost[];
}
// Get recent posts with optional tag filtering
export function getRecentPostsByTags(tags: string[], limit: number = 10): BlogPost[] {
if (tags.length === 0) {
return getRecentPosts(limit);
}
// Build query for posts with all specified tags
const placeholders = tags.map(() => '?').join(',');
const query = 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 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 ?
`);
return query.all(...tags, tags.length, limit) as BlogPost[];
}
// Helper function to get recent posts
function getRecentPosts(limit: number = 10): BlogPost[] {
const query = db.query(`
SELECT * FROM posts
WHERE path NOT LIKE '%.md'
ORDER BY date DESC
LIMIT ?
`);
return query.all(limit) as BlogPost[];
}
// Search posts by title or content
export function searchPosts(query: string, limit: number = 20): BlogPost[] {
const searchQuery = db.query(`
SELECT * FROM posts
WHERE (title LIKE ? OR content LIKE ?)
ORDER BY date DESC
LIMIT ?
`);
const searchPattern = `%${query}%`;
return searchQuery.all(searchPattern, searchPattern, limit) as BlogPost[];
}
// Get posts by date range
export function getPostsByDateRange(startDate: string, endDate: string): BlogPost[] {
const dateQuery = db.query(`
SELECT * FROM posts
WHERE date BETWEEN ? AND ?
ORDER BY date DESC
`);
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

@@ -1,77 +0,0 @@
import { db } from './db';
// Helper function to get or create a tag and return its ID
export function getOrCreateTag(tagName: string): number {
// Try to find existing tag
const findTag = db.query('SELECT id FROM tags WHERE name = ?');
const existingTag = findTag.get(tagName) as { id: number } | undefined;
if (existingTag) {
return existingTag.id;
}
// Create new tag if it doesn't exist
const insertTag = db.query('INSERT INTO tags (name) VALUES (?) RETURNING id');
const result = insertTag.get(tagName) as { id: number };
return result.id;
}
// Helper function to get tags for a post
export function getPostTags(postId: number): string[] {
const query = db.query(`
SELECT t.name FROM tags t
JOIN post_tags pt ON t.id = pt.tag_id
WHERE pt.post_id = ?
`);
const results = query.all(postId) as { name: string }[];
return results.map(row => row.name);
}
// Helper function to parse tags
export function parseTags(tags: string | string[]): string[] {
// If tags is already an array, return it directly
if (Array.isArray(tags)) {
return tags;
}
// If tags is a string, try to parse as JSON
try {
return JSON.parse(tags || '[]');
} catch {
// If parsing fails, assume it's a comma-separated string
if (typeof tags === 'string' && tags.trim()) {
return tags.split(',').map(tag => tag.trim()).filter(Boolean);
}
return [];
}
}
// Get all unique tags from database
export function getAllTags(): { name: string; post_count: number }[] {
const query = db.query(`
SELECT t.name, COUNT(pt.post_id) as post_count
FROM tags t
JOIN post_tags pt ON t.id = pt.tag_id
JOIN posts p ON pt.post_id = p.id
GROUP BY t.id, t.name
ORDER BY post_count DESC, t.name ASC
`);
return query.all() as { name: string; post_count: number }[];
}
// Update tag counts after changes (no longer needed with the new structure)
// The post_count is calculated dynamically in getAllTags()
// Keeping the function for backward compatibility
export function updateTagCounts() {
console.log("Tag counts are now calculated dynamically in getAllTags()");
}
// Initialize tags table and populate with existing tags
// This function is deprecated as we've restructured the database
// The new database structure is initialized in index.ts
// Kept for backward compatibility
export function initializeTagsTable() {
console.log("Tags table is now initialized in index.ts with the new structure");
}

View File

@@ -1,7 +1,9 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { minifyCSS, minifyJS } from './utils'; import { minifyCSS, minifyJS } from './utils';
// @ts-expect-error - Importing as text, not a module
import styles from './styles.css' with { type: "text" }; import styles from './styles.css' with { type: "text" };
// @ts-expect-error - Importing as text, not a module
import headScript from './onLoad' with { type: "text" }; import headScript from './onLoad' with { type: "text" };
import { ThemePicker } from './components/theme-picker'; import { ThemePicker } from './components/theme-picker';
@@ -9,7 +11,7 @@ import { ProfileBadge } from './components/profile-badge';
import { TagPicker } from './components/tag-picker'; import { TagPicker } from './components/tag-picker';
import { PostArchive } from './components/post-archive'; import { PostArchive } from './components/post-archive';
export function AppShell({ children }: { children: ReactNode }) { export function AppShell({ children, searchParams }: { children: ReactNode, searchParams?: URLSearchParams }) {
return ( return (
<html lang="en"> <html lang="en">
<head> <head>
@@ -23,7 +25,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<aside> <aside>
<ThemePicker /> <ThemePicker />
<ProfileBadge /> <ProfileBadge />
<TagPicker /> <TagPicker searchParams={searchParams} />
<PostArchive /> <PostArchive />
</aside> </aside>
</body> </body>

View File

@@ -245,3 +245,54 @@ window.addEventListener('popstate', () => {
// Update the active post indicator after initializing the archive state // Update the active post indicator after initializing the archive state
setTimeout(updateActivePost, 10); // Small delay to ensure DOM is updated setTimeout(updateActivePost, 10); // Small delay to ensure DOM is updated
}); });
// Listen for URL changes (including navigation via other components)
function setupUrlChangeListener() {
// Store the current URL to detect changes
let currentUrl = window.location.pathname;
// Use MutationObserver to detect DOM changes in the main element
// This helps catch navigation changes from other components
const observer = new MutationObserver((mutations) => {
// Check if URL has changed
if (window.location.pathname !== currentUrl) {
currentUrl = window.location.pathname;
// Wait a bit for any DOM updates to complete
setTimeout(() => {
updateActivePost();
initializeArchiveState();
}, 50);
}
});
// Start observing the main element for changes
const mainElement = document.querySelector('main');
if (mainElement) {
observer.observe(mainElement, { childList: true, subtree: true });
}
// Also observe direct URL changes (e.g., when using browser back/forward)
const intervalId = setInterval(() => {
if (window.location.pathname !== currentUrl) {
currentUrl = window.location.pathname;
updateActivePost();
initializeArchiveState();
}
}, 500); // Check every 500ms
// Clean up on page unload
window.addEventListener('beforeunload', () => {
clearInterval(intervalId);
observer.disconnect();
});
}
// Initialize the URL change listener
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setupUrlChangeListener();
});
} else {
setupUrlChangeListener();
}

View File

@@ -1,102 +1,119 @@
// Client-side tag picker toggle functionality // Add function to 'Clear all filters'
class TagPickerManager { document.querySelector('.clear-tags-btn')
constructor() { ?.addEventListener('click', (e) => {
this.init(); const activeTags = document.querySelectorAll('a.tag-pill.active')
} activeTags.forEach((activeTag) => {
activeTag.classList.remove('active')
})
updateTagUrls();
})
init() { // Add function to toggle tag filters
this.attachTagClickListeners(); document.querySelectorAll('a.tag-pill')
} .forEach((tagPill) => {
tagPill.addEventListener('click', (e) => toggleTagPill(e))
})
// Parse current URL to get active tags function toggleTagPill(e: Event) {
getActiveTags(): string[] { const target = e.currentTarget as HTMLElement;
const urlParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search)
return urlParams.getAll('tag').map(tag => decodeURIComponent(tag).replace(/-/g, ' '));
}
// Generate query string from tags array if (target.classList.contains('active')) {
generateTagQueryString(tags: string[]): string { target.classList.remove('active')
if (tags.length === 0) return ''; searchParams.delete('tag', getURLSafeTagName(target.innerText))
const params = new URLSearchParams(); } else {
tags.forEach(tag => { target.classList.add('active')
params.append('tag', tag.toLowerCase().replace(/\s+/g, '-')); searchParams.append('tag', getURLSafeTagName(target.innerText))
}); }
return params.toString();
}
// Toggle a tag and update the page // Update tag urls after the loader in onLoad completes
toggleTag(tagName: string) { setTimeout(() => updateTagUrls(), 0)
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();
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 page load function updateTagUrls() {
const tagPickerManager = new TagPickerManager(); const activeTags = document.querySelectorAll('a.tag-pill.active')
const inactiveTags = document.querySelectorAll('a.tag-pill:not(.active)')
// Update visual state after page loads let baseTagParams = '';
document.addEventListener('DOMContentLoaded', () => { activeTags.forEach((val) => {
tagPickerManager.updateTagVisualState(); baseTagParams += `&tag=${getURLSafeTagName(val.innerHTML)}`
})
inactiveTags.forEach((val) => {
val.setAttribute('href', `/?tag=${getURLSafeTagName(val.innerHTML)}${baseTagParams}`)
})
activeTags.forEach((val) => {
const tagName = getURLSafeTagName(val.innerHTML)
const activeTagLink = baseTagParams.split(`&tag=${getURLSafeTagName(val.innerHTML)}`).join('')
if (activeTagLink.length > 1) {
val.setAttribute('href', `/?${activeTagLink.substring(1)}`)
} else {
val.setAttribute('href', '/')
}
})
}
// Helper function normalizing the user facing tag name into
// the url format for the tag name.
function getURLSafeTagName(tag: string) {
return tag.toLowerCase().split(" ").join("-")
}
// Function to sync the UI state with the current URL
function syncStateFromUrl() {
const searchParams = new URLSearchParams(window.location.search);
const currentTags = searchParams.getAll('tag');
// Reset all tags to inactive
document.querySelectorAll('a.tag-pill').forEach(tagPill => {
tagPill.classList.remove('active');
});
// Set active tags based on current URL
let activeTagCount = 0
currentTags.forEach(tagUrl => {
document.querySelectorAll('a.tag-pill').forEach(tagPill => {
const pill = tagPill as HTMLElement;
if (getURLSafeTagName(pill.innerText) === tagUrl) {
pill.classList.add('active');
activeTagCount++
}
});
});
// If there is an active tag, show the clear filters button
const tagActions = document.querySelector('.tag-actions') as HTMLElement
if (activeTagCount > 0) {
// Show the clear tags button
tagActions.style.display = 'block';
} else {
// Hide the clear tags button
tagActions.style.display = 'none';
}
updateTagUrls();
}
// Hook into the popstate event to sync state when navigating back/forward
window.addEventListener('popstate', () => {
syncStateFromUrl();
}); });
// Also call immediately if DOM is already loaded // Hook into the navigation system to sync state after content loads
if (document.readyState === 'interactive' || document.readyState === 'complete') { // We'll observe the main element for changes
tagPickerManager.updateTagVisualState(); const observer = new MutationObserver((mutations) => {
} mutations.forEach((mutation) => {
if (mutation.type === 'childList' && document.querySelector('main')) {
syncStateFromUrl();
}
});
});
export { TagPickerManager }; // Start observing the document body for changes
observer.observe(document.body, {
childList: true,
subtree: true
});

View File

@@ -1,3 +1,6 @@
// JS is enabled, show theme picker interface
const elem = document.querySelector(".theme-controls")?.classList.remove('hidden')
// Theme switching functionality // Theme switching functionality
const LIGHT_THEMES = ['latte', 'solarized-light', 'gruvbox-light']; const LIGHT_THEMES = ['latte', 'solarized-light', 'gruvbox-light'];
const DARK_THEMES = ['frappe', 'macchiato', 'mocha', 'solarized-dark', 'gruvbox-dark', 'nord', 'dracula', 'one-dark', 'tokyo-night']; const DARK_THEMES = ['frappe', 'macchiato', 'mocha', 'solarized-dark', 'gruvbox-dark', 'nord', 'dracula', 'one-dark', 'tokyo-night'];

View File

@@ -1,7 +1,25 @@
import React from 'react'; import React from 'react';
import postArchiveScript from '../clientJS/post-archive' with { type: "text" };
import { getPostsByYearAndMonth } from '../../db';
import { minifyJS } from '../utils'; import { minifyJS } from '../utils';
import { db } from '../../db';
// @ts-expect-error - Importing as text, not a module
import postArchiveScript from '../clientJS/post-archive' with { type: "text" };
// 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[];
}
// Read posts from database once and store in a closure // Read posts from database once and store in a closure
const getArchiveData = (() => { const getArchiveData = (() => {
@@ -11,11 +29,10 @@ const getArchiveData = (() => {
if (!cachedData) { if (!cachedData) {
cachedData = getPostsByYearAndMonth(); cachedData = getPostsByYearAndMonth();
} }
return cachedData; return cachedData;
}; };
})(); })();
export function PostArchive() { export function PostArchive() {
const archiveData = getArchiveData(); const archiveData = getArchiveData();
@@ -59,3 +76,69 @@ export function PostArchive() {
</div> </div>
) )
} }
// 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

@@ -1,151 +1,63 @@
import React from 'react'; import React from 'react';
import { minifyJS } from '../utils'; import { minifyJS } from '../utils';
import { dbConnection } from '../../db/index.js';
// @ts-expect-error - Importing as text, not a module
import tagPickerScript from '../clientJS/tag-picker.js' with { type: "text" };
const tags = dbConnection.getAllTags().map(tag => ({
name: tag.name,
post_count: tag.post_count,
urlNormalized: tag.urlNormalized
}));
export function TagPicker({ searchParams }: { searchParams?: URLSearchParams }) {
const selectedTags = typeof searchParams === 'object' ? searchParams.getAll('tag') : [];
export function TagPicker({ availableTags = [], activeTags = [] }: {
availableTags?: { name: string; post_count: number }[],
activeTags?: string[]
}) {
return ( return (
<div className="tags sheet-background"> <div className="tags sheet-background">
<h3>Tags</h3> <h3>Tags</h3>
{availableTags.length > 0 ? ( <ul className="tag-pills">
<ul className="tag-pills"> {tags.map(tag => {
{availableTags.map(tag => ( let active = selectedTags.includes(tag.urlNormalized)
<li key={tag.name}> let hyperlink = `/?tag=${tag.urlNormalized}`;
<a
href={`?tag=${tag.name.toLowerCase().replace(/\s+/g, '-')}`} for (const existingTag of selectedTags) {
className="tag-pill" hyperlink += `&tag=${existingTag}`
title={`${tag.post_count} post${tag.post_count !== 1 ? 's' : ''} (click to toggle)`} }
>
{tag.name} // Remove the currently selected tag from the tag link url if applicable
</a> if (active) {
</li> hyperlink = hyperlink.split(`?tag=${tag.urlNormalized}`).join('')
))} hyperlink = hyperlink.split(`&tag=${tag.urlNormalized}`).join('')
</ul> }
) : (
<p className="no-tags-available">No tags available</p> // Make sure the hyperlink starts with a ? and not a & (or the root webpage)
)} hyperlink = '/?' + hyperlink.substring(2)
<div className="tag-actions" style={{ display: 'none' }}> if(hyperlink.length == 2) hyperlink = '/'
return (
< li key={tag.name} >
<a
title={active ? `Remove ${tag.name} from filter` : `Add ${tag.name} to filter`}
data-tag
className={`tag-pill ${active ? 'active' : ''}`}
href={hyperlink}
>
{tag.name}
</a>
</li>
)
})}
</ul>
{/* Show clear tags button if there are selected tags */}
<div className="tag-actions" style={{ display: selectedTags.length > 0 ? 'block' : 'none' }}>
<a href="/" className="clear-tags-btn"> <a href="/" className="clear-tags-btn">
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();
}
})();
`;

View File

@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import themePickerScript from '../clientJS/theme-picker' with { type: "text" };
import { minifyJS } from '../utils'; import { minifyJS } from '../utils';
// @ts-expect-error - Importing as text, not a module
import themePickerScript from '../clientJS/theme-picker' with { type: "text" };
const LIGHT_THEMES = ['latte', 'solarized-light', 'gruvbox-light']; const LIGHT_THEMES = ['latte', 'solarized-light', 'gruvbox-light'];
const DARK_THEMES = ['frappe', 'macchiato', 'mocha', 'solarized-dark', 'gruvbox-dark', 'nord', 'dracula', 'one-dark', 'tokyo-night']; const DARK_THEMES = ['frappe', 'macchiato', 'mocha', 'solarized-dark', 'gruvbox-dark', 'nord', 'dracula', 'one-dark', 'tokyo-night'];
@@ -22,9 +24,10 @@ const THEME_NAMES: Record<string, string> = {
export function ThemePicker() { export function ThemePicker() {
return ( return (
<div className="themePicker sheet-background"> <div className="themePicker sheet-background">
<noscript>Enable Javascript to select a theme</noscript>
<label htmlFor="theme" className="hidden">Theme</label> <label htmlFor="theme" className="hidden">Theme</label>
<div className="theme-controls"> <div className="theme-controls hidden">
<div className="theme-mode-toggle"> <div className="theme-mode-toggle">
<button <button
className="mode-btn active" className="mode-btn active"

View File

@@ -1,7 +1,7 @@
// Client-side script that runs on page load // Client-side script that runs on page load
// Example: TypeScript with type annotations // Example: TypeScript with type annotations
async function loadContent(url: string) { async function loadContent(url: string, shouldScrollToTop: boolean = true) {
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'shell-loaded': 'true' 'shell-loaded': 'true'
@@ -13,6 +13,10 @@ async function loadContent(url: string) {
mainElement.outerHTML = html; mainElement.outerHTML = html;
// Re-attach handlers to new links after content swap // Re-attach handlers to new links after content swap
attachLinkHandlers(); attachLinkHandlers();
// Smooth scroll to top after navigation if needed
if (shouldScrollToTop) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
} }
} }
@@ -20,21 +24,20 @@ 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();
e.stopPropagation(); e.stopPropagation();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
window.history.pushState({}, '', link.href); window.history.pushState({}, '', link.href);
await loadContent(link.href); await loadContent(link.href, true);
} }
}); });
} }
// Listen for back/forward button clicks // Listen for back/forward button clicks
window.addEventListener('popstate', async (event) => { window.addEventListener('popstate', async (event) => {
await loadContent(window.location.href); await loadContent(window.location.href, false);
}); });
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@@ -1,14 +1,29 @@
import React from 'react'; import React from 'react';
import { getRecentPosts, formatDate, calculateReadTime, getNumOfPosts } from '../../db/posts';
import { parseTags } from '../../db/tags';
import { type BlogPost } from '../../db/queries';
export function Home({ searchParams }: { searchParams: Record<string, string> }) { import { dbConnection } from '../../db';
const currentPage = parseInt(searchParams.page || "1", 10); import { formatDate } from '../utils';
// Extract the post type from the database return type
type Post = {
id: number;
path: string;
title: string;
date: string;
readingTime: string;
summary: string;
content: string;
tags: string[];
};
export function Home({ searchParams }: { searchParams: URLSearchParams }) {
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(dbConnection.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 = dbConnection.getPosts(postsPerPage, offset, tags); // Get posts for the current page
return ( return (
<main> <main>
@@ -24,17 +39,13 @@ export function Home({ searchParams }: { searchParams: Record<string, string> })
</div> </div>
)} )}
</div> </div>
<Pagination currentPage={currentPage} totalPages={totalPages} /> <Pagination searchParams={searchParams} currentPage={currentPage} totalPages={totalPages} />
</main> </main>
); );
} }
interface PostCardProps { function PostCard({ post }: { post: Post }) {
post: BlogPost; const tags = post.tags;
}
function PostCard({ post }: PostCardProps) {
const tags = parseTags(post.tags);
const formattedDate = formatDate(post.date); const formattedDate = formatDate(post.date);
return ( return (
@@ -74,7 +85,7 @@ function PostCard({ post }: PostCardProps) {
); );
} }
function Pagination({ currentPage, totalPages }: { currentPage: number; totalPages: number }) { function Pagination({ currentPage, totalPages, searchParams }:{ currentPage: number; totalPages: number, searchParams: URLSearchParams }) {
// Calculate the range of page numbers to show // Calculate the range of page numbers to show
let startPage: number; let startPage: number;
let endPage: number; let endPage: number;
@@ -102,11 +113,17 @@ function Pagination({ currentPage, totalPages }: { currentPage: number; totalPag
const pages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i); const pages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
let passThroughParams = ''
searchParams.forEach((val, key) => {
if(key != 'page')
passThroughParams += `&${key}=${val}`
})
return ( return (
<nav className="pagination"> <nav className="pagination">
{/* Previous button - always visible, disabled on first page */} {/* Previous button - always visible, disabled on first page */}
<a <a
href={`/?page=${currentPage - 1}`} href={`/?page=${currentPage - 1}${passThroughParams}`}
className={`pagination-link ${currentPage === 1 ? 'disabled' : ''}`} className={`pagination-link ${currentPage === 1 ? 'disabled' : ''}`}
style={{ style={{
opacity: currentPage === 1 ? 0.5 : 1, opacity: currentPage === 1 ? 0.5 : 1,
@@ -121,7 +138,7 @@ function Pagination({ currentPage, totalPages }: { currentPage: number; totalPag
{pages.map((page) => ( {pages.map((page) => (
<a <a
key={page} key={page}
href={`/?page=${page}`} href={`/?page=${page}${passThroughParams}`}
className={`pagination-link ${page === currentPage ? 'active' : ''}`} className={`pagination-link ${page === currentPage ? 'active' : ''}`}
> >
{page} {page}
@@ -130,7 +147,7 @@ function Pagination({ currentPage, totalPages }: { currentPage: number; totalPag
{/* Next button - always visible, disabled on last page */} {/* Next button - always visible, disabled on last page */}
<a <a
href={`/?page=${currentPage + 1}`} href={`/?page=${currentPage + 1}${passThroughParams}`}
className={`pagination-link ${currentPage === totalPages ? 'disabled' : ''}`} className={`pagination-link ${currentPage === totalPages ? 'disabled' : ''}`}
style={{ style={{
opacity: currentPage === totalPages ? 0.5 : 1, opacity: currentPage === totalPages ? 0.5 : 1,

View File

@@ -1,5 +1,10 @@
import React from 'react'; import React from 'react';
interface NavigationPost {
title: string;
path: string;
}
interface PostProps { interface PostProps {
// HTML string for the blog post body // HTML string for the blog post body
children: string; children: string;
@@ -7,14 +12,23 @@ interface PostProps {
title: string; title: string;
date: Date; date: Date;
readingTime: string; readingTime: string;
previousPost?: NavigationPost | null;
nextPost?: NavigationPost | null;
}; };
} }
export function Post({ children, meta }: PostProps) { export function Post({ children, meta }: PostProps) {
const { previousPost, nextPost } = meta;
return ( return (
<main> <main>
<article className="blog-post"> <article className="blog-post">
<header className="post-header"> <header className="post-header">
<div className="back-button">
<a href="/" className="back-link">
<span className="back-arrow"></span> Back to Home
</a>
</div>
<h1>{meta.title}</h1> <h1>{meta.title}</h1>
<div className="post-meta"> <div className="post-meta">
{meta.date && meta.date instanceof Date && {meta.date && meta.date instanceof Date &&
@@ -39,6 +53,22 @@ export function Post({ children, meta }: PostProps) {
</div> </div>
</header> </header>
<div className="post-content" dangerouslySetInnerHTML={{ __html: children }} /> <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> </article>
</main> </main>
) )

View File

@@ -1,3 +1,12 @@
/* Default theme fallback for when JS is disabled */
html {
--bg-primary: #eff1f5;
--bg-secondary: #e6e9f0;
--text-primary: #4c4f69;
--text-secondary: #6c6f85;
--border-color: #dce0e8;
}
/* Catppuccin Latte - Light theme */ /* Catppuccin Latte - Light theme */
html[data-theme="latte"] { html[data-theme="latte"] {
--bg-primary: #eff1f5; --bg-primary: #eff1f5;
@@ -569,9 +578,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 */
@@ -728,6 +734,28 @@ h1 {
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
.back-button {
margin-bottom: 20px;
}
.back-link {
display: inline-flex;
align-items: center;
color: var(--text-color-secondary);
text-decoration: none;
font-size: 14px;
transition: color 0.2s ease;
}
.back-link:hover {
color: var(--accent-color);
}
.back-arrow {
margin-right: 8px;
font-size: 16px;
}
.post-title { .post-title {
font-size: 42px; font-size: 42px;
font-weight: 700; font-weight: 700;
@@ -914,6 +942,64 @@ h1 {
margin: 0; 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 */ /* Responsive adjustments */
@media (max-width: 1200px) { @media (max-width: 1200px) {
aside { aside {
@@ -1020,21 +1106,42 @@ h1 {
font-size: 28px; font-size: 28px;
} }
.back-button {
margin-bottom: 12px;
}
.back-link {
font-size: 13px;
}
.back-arrow {
font-size: 15px;
margin-right: 6px;
}
.post-content h2 { .post-content h2 {
font-size: 24px; font-size: 24px;
} }
.post-content h3 { .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 { .tag-pills {
gap: 6px; display: flex;
} }
.tag-pill { .tag-pill {
font-size: 12px; padding: 6px 12px;
padding: 4px 10px;
} }
} }

View File

@@ -16,3 +16,22 @@ export function minifyJS(code: string): string {
return transpiler.transformSync(code); return transpiler.transformSync(code);
} }
// Helper function to calculate read time
export function calculateReadTime(content: string): number {
const wordsPerMinute = 200;
const words = content.split(/\s+/).length;
return Math.max(1, Math.ceil(words / wordsPerMinute));
}
// Helper function to format date for display
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',
day: 'numeric'
});
}

View File

@@ -99,5 +99,17 @@
/* Completeness */ /* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true "skipLibCheck": true
} },
"include": [
"index.tsx",
"src/**/*",
"bun_plugins/**/*",
"*.d.ts"
],
"exclude": [
"node_modules",
"dist",
"build",
"content"
]
} }