Cleanup AI slop and simplify db interface
This commit is contained in:
parent
88b6d1bade
commit
6664e6e3d1
@ -4,7 +4,7 @@ import { renderToString } from "react-dom/server";
|
||||
|
||||
import matter from 'gray-matter';
|
||||
import { marked } from 'marked';
|
||||
import { addToDatabase } from "../src/db/index";
|
||||
import { dbConnection } from "../src/db/index";
|
||||
|
||||
import { AppShell } from "../src/frontend/AppShell";
|
||||
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`
|
||||
};
|
||||
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
|
||||
return {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import matter from 'gray-matter';
|
||||
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
|
||||
// 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);
|
||||
addToDatabase(route, data, bodyHtml);
|
||||
dbConnection.addPost(route, data, bodyHtml);
|
||||
}
|
||||
|
||||
console.log('Posts have been imported into db');
|
||||
|
||||
13
index.tsx
13
index.tsx
@ -6,7 +6,7 @@ import { Home } from "./src/frontend/pages/home";
|
||||
import { NotFound } from "./src/frontend/pages/not-found";
|
||||
import demo from "./temp/appshell.html";
|
||||
import { Post } from "./src/frontend/pages/post";
|
||||
import { getPostWithTags, getAdjacentPosts } from "./src/db/index";
|
||||
import { dbConnection } from "./src/db";
|
||||
|
||||
async function blogPosts(hmr: boolean) {
|
||||
const glob = new Bun.Glob("**/*.md");
|
||||
@ -14,15 +14,16 @@ async function blogPosts(hmr: boolean) {
|
||||
for await (const file of glob.scan("./content")) {
|
||||
const post = await import(`./content/${file}`, { with: { type: "html" } });
|
||||
const route = `/${file.replace(/\.md$/, "")}`;
|
||||
dbConnection.getAllTags();
|
||||
|
||||
if (hmr) {
|
||||
// Use Bun Importer plugin for hot reloading in the browser
|
||||
blogPosts[`/hmr${route}`] = post.default;
|
||||
} else {
|
||||
// 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 post = getPostWithTags(path);
|
||||
const post = dbConnection.getPost(path);
|
||||
if (!post)
|
||||
return new Response(renderToString(<NotFound />), {
|
||||
status: 404,
|
||||
@ -30,7 +31,7 @@ async function blogPosts(hmr: boolean) {
|
||||
});
|
||||
|
||||
// Get adjacent posts for navigation
|
||||
const { previousPost, nextPost } = getAdjacentPosts(post.path);
|
||||
const { previousPost, nextPost } = dbConnection.getAdjacentPosts(post.path);
|
||||
|
||||
const data = {
|
||||
title: post.title,
|
||||
@ -91,7 +92,7 @@ Bun.serve({
|
||||
...(await blogPosts(false)),
|
||||
|
||||
// hot module replacement in development mode
|
||||
...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : []),
|
||||
...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : {}),
|
||||
|
||||
// Home page
|
||||
"/": (req: Request) => {
|
||||
@ -108,7 +109,7 @@ Bun.serve({
|
||||
|
||||
return new Response(
|
||||
renderToString(
|
||||
<AppShell>
|
||||
<AppShell searchParams={searchParams}>
|
||||
<Home searchParams={searchParams} />
|
||||
</AppShell>,
|
||||
),
|
||||
|
||||
66
src/db/db.ts
66
src/db/db.ts
@ -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();
|
||||
431
src/db/index.ts
431
src/db/index.ts
@ -1,8 +1,425 @@
|
||||
// Import the database connection
|
||||
import { db } from './db';
|
||||
export { db } from './db';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import path from 'path';
|
||||
|
||||
// Export all functions related to database operations
|
||||
export * from './posts';
|
||||
export * from './tags';
|
||||
export * from './queries';
|
||||
/**
|
||||
* Singleton database connection class for managing blog posts and tags
|
||||
*/
|
||||
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();
|
||||
|
||||
275
src/db/posts.ts
275
src/db/posts.ts
@ -1,275 +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)
|
||||
// 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 = 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(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
|
||||
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, tags: string[] = []) {
|
||||
let posts;
|
||||
|
||||
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 => ({
|
||||
...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 {
|
||||
// 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'
|
||||
});
|
||||
}
|
||||
|
||||
// Get adjacent posts (previous and next) chronologically for a given post
|
||||
export function getAdjacentPosts(currentPostPath: string) {
|
||||
const allPostsQuery = 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 };
|
||||
}
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { minifyCSS, minifyJS } from './utils';
|
||||
|
||||
// @ts-expect-error - Importing as text, not a module
|
||||
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 { ThemePicker } from './components/theme-picker';
|
||||
@ -9,7 +11,7 @@ import { ProfileBadge } from './components/profile-badge';
|
||||
import { TagPicker } from './components/tag-picker';
|
||||
import { PostArchive } from './components/post-archive';
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
export function AppShell({ children, searchParams }: { children: ReactNode, searchParams?: URLSearchParams }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ -23,7 +25,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<aside>
|
||||
<ThemePicker />
|
||||
<ProfileBadge />
|
||||
<TagPicker />
|
||||
<TagPicker searchParams={searchParams} />
|
||||
<PostArchive />
|
||||
</aside>
|
||||
</body>
|
||||
|
||||
@ -1,100 +1,100 @@
|
||||
// Client-side tag picker toggle functionality
|
||||
class TagPickerManager {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
// // Client-side tag picker toggle functionality
|
||||
// class TagPickerManager {
|
||||
// constructor() {
|
||||
// this.init();
|
||||
// }
|
||||
|
||||
init() {
|
||||
this.attachTagClickListeners();
|
||||
}
|
||||
// init() {
|
||||
// this.attachTagClickListeners();
|
||||
// }
|
||||
|
||||
// Parse current URL to get active tags
|
||||
getActiveTags(): string[] {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.getAll('tag').map(tag => decodeURIComponent(tag).replace(/-/g, ' '));
|
||||
}
|
||||
// // Parse current URL to get active tags
|
||||
// getActiveTags(): string[] {
|
||||
// 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: string[]): string {
|
||||
if (tags.length === 0) return '';
|
||||
const params = new URLSearchParams();
|
||||
tags.forEach(tag => {
|
||||
params.append('tag', tag.toLowerCase().replace(/\s+/g, '-'));
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
// // Generate query string from tags array
|
||||
// generateTagQueryString(tags: string[]): string {
|
||||
// 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: string) {
|
||||
const currentTags = this.getActiveTags();
|
||||
// // 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)
|
||||
? currentTags.filter(t => t !== tagName)
|
||||
: [...currentTags, tagName];
|
||||
// // 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;
|
||||
}
|
||||
// // 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');
|
||||
// // Attach click listeners to tag links
|
||||
// attachTagClickListeners() {
|
||||
// const tagLinks = document.querySelectorAll('[data-taglink], .post-tag');
|
||||
|
||||
tagLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const tagName = link.textContent?.trim();
|
||||
if (tagName) {
|
||||
this.toggleTag(tagName);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// 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 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 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 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
// // 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
|
||||
const tagPickerManager = new TagPickerManager();
|
||||
// // Initialize on page load
|
||||
// const tagPickerManager = new TagPickerManager();
|
||||
|
||||
// Update visual state after page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
tagPickerManager.updateTagVisualState();
|
||||
});
|
||||
// // Update visual state after page loads
|
||||
// document.addEventListener('DOMContentLoaded', () => {
|
||||
// tagPickerManager.updateTagVisualState();
|
||||
// });
|
||||
|
||||
// Also call immediately if DOM is already loaded
|
||||
if (document.readyState === 'interactive' || document.readyState === 'complete') {
|
||||
tagPickerManager.updateTagVisualState();
|
||||
}
|
||||
// // Also call immediately if DOM is already loaded
|
||||
// if (document.readyState === 'interactive' || document.readyState === 'complete') {
|
||||
// tagPickerManager.updateTagVisualState();
|
||||
// }
|
||||
|
||||
@ -1,24 +1,41 @@
|
||||
import React from 'react';
|
||||
import postArchiveScript from '../clientJS/post-archive' with { type: "text" };
|
||||
import { getPostsByYearAndMonth } from '../../db';
|
||||
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
|
||||
const getArchiveData = (() => {
|
||||
let cachedData: ReturnType<typeof getPostsByYearAndMonth> | null = null;
|
||||
|
||||
|
||||
return () => {
|
||||
if (!cachedData) {
|
||||
cachedData = getPostsByYearAndMonth();
|
||||
}
|
||||
return cachedData;
|
||||
return cachedData;
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
export function PostArchive() {
|
||||
const archiveData = getArchiveData();
|
||||
|
||||
|
||||
return (
|
||||
<div className="postList sheet-background">
|
||||
<h3>Posts</h3>
|
||||
@ -59,3 +76,69 @@ export function PostArchive() {
|
||||
</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)
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,38 +1,49 @@
|
||||
import React from 'react';
|
||||
import tagPickerScript from '../clientJS/tag-picker.js' with { type: "text" };
|
||||
import { minifyJS } from '../utils';
|
||||
import { dbConnection } from '../../db/index.js';
|
||||
|
||||
import { getAllTags } from '../../db/tags';
|
||||
// @ts-expect-error - Importing as text, not a module
|
||||
import tagPickerScript from '../clientJS/tag-picker.js' with { type: "text" };
|
||||
|
||||
const tags = getAllTags();
|
||||
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() {
|
||||
return (
|
||||
<div className="tags sheet-background">
|
||||
<h3>Tags</h3>
|
||||
{tags.length > 0 ? (
|
||||
<ul className="tag-pills">
|
||||
{tags.map(tag => (
|
||||
<li key={tag.name}>
|
||||
<a
|
||||
data-tag
|
||||
href={`?tag=${tag.name.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
className="tag-pill"
|
||||
title={`${tag.post_count} post${tag.post_count !== 1 ? 's' : ''} (click to view)`}
|
||||
>
|
||||
{tag.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="no-tags-available">No tags available</p>
|
||||
<ul className="tag-pills">
|
||||
{tags.map(tag => {
|
||||
const active = selectedTags.includes(tag.urlNormalized)
|
||||
|
||||
return (
|
||||
< li key={tag.name} >
|
||||
<a
|
||||
title={`${tag.post_count} post${tag.post_count !== 1 ? 's' : ''} (click to view)`}
|
||||
data-tag
|
||||
className={`tag-pill ${active ? 'active' : ''}`}
|
||||
href={`?tag=${tag.urlNormalized}`}
|
||||
>
|
||||
{tag.name}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* Show clear tags button if there are selected tags */}
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="tag-actions">
|
||||
<a href="/" className="clear-tags-btn">
|
||||
Clear all filters
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="tag-actions" style={{ display: 'none' }}>
|
||||
<a href="/" className="clear-tags-btn">
|
||||
Clear all filters
|
||||
</a>
|
||||
</div>
|
||||
<script dangerouslySetInnerHTML={{ __html: minifyJS(tagPickerScript) }} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import themePickerScript from '../clientJS/theme-picker' with { type: "text" };
|
||||
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 DARK_THEMES = ['frappe', 'macchiato', 'mocha', 'solarized-dark', 'gruvbox-dark', 'nord', 'dracula', 'one-dark', 'tokyo-night'];
|
||||
|
||||
@ -26,15 +28,15 @@ export function ThemePicker() {
|
||||
<label htmlFor="theme" className="hidden">Theme</label>
|
||||
<div className="theme-controls">
|
||||
<div className="theme-mode-toggle">
|
||||
<button
|
||||
className="mode-btn active"
|
||||
<button
|
||||
className="mode-btn active"
|
||||
data-mode="light"
|
||||
id="lightModeBtn"
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
className="mode-btn"
|
||||
<button
|
||||
className="mode-btn"
|
||||
data-mode="dark"
|
||||
id="darkModeBtn"
|
||||
>
|
||||
@ -42,8 +44,8 @@ export function ThemePicker() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="theme-dropdown-wrapper">
|
||||
<button
|
||||
className="theme-dropdown-trigger"
|
||||
<button
|
||||
className="theme-dropdown-trigger"
|
||||
id="themeDropdownTrigger"
|
||||
>
|
||||
<span id="currentThemeDisplay">Catppuccin Latte</span>
|
||||
@ -62,4 +64,4 @@ export function ThemePicker() {
|
||||
<script dangerouslySetInnerHTML={{ __html: minifyJS(themePickerScript) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,29 @@
|
||||
import React from 'react';
|
||||
import { getRecentPosts, formatDate, calculateReadTime, getNumOfPosts } from '../../db/posts';
|
||||
import { parseTags } from '../../db/tags';
|
||||
import { type BlogPost } from '../../db/queries';
|
||||
|
||||
import { dbConnection } from '../../db';
|
||||
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 tags = searchParams.getAll('tag');
|
||||
|
||||
const currentPage = parseInt(searchParams.get('page') || "1", 10);
|
||||
const totalPages = Math.ceil(getNumOfPosts(tags) / postsPerPage);
|
||||
const totalPages = Math.ceil(dbConnection.getNumOfPosts(tags) / postsPerPage);
|
||||
const offset = (currentPage - 1) * postsPerPage;
|
||||
|
||||
|
||||
const posts = getRecentPosts(postsPerPage, offset, tags); // Get posts for the current page
|
||||
const posts = dbConnection.getPosts(postsPerPage, offset, tags); // Get posts for the current page
|
||||
|
||||
return (
|
||||
<main>
|
||||
@ -33,12 +44,8 @@ export function Home({ searchParams }: { searchParams: URLSearchParams }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface PostCardProps {
|
||||
post: BlogPost;
|
||||
}
|
||||
|
||||
function PostCard({ post }: PostCardProps) {
|
||||
const tags = parseTags(post.tags);
|
||||
function PostCard({ post }: { post: Post }) {
|
||||
const tags = post.tags;
|
||||
const formattedDate = formatDate(post.date);
|
||||
|
||||
return (
|
||||
|
||||
@ -15,4 +15,23 @@ export function minifyJS(code: string): string {
|
||||
});
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
@ -99,5 +99,17 @@
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"index.tsx",
|
||||
"src/**/*",
|
||||
"bun_plugins/**/*",
|
||||
"*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
"content"
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user